@hublo/sentinel 1.4.0-alpha.4 → 1.4.0-alpha.40
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 +1 -0
- package/dist/bin/sentinel.d.ts +1 -0
- package/dist/bin/sentinel.js +45 -102
- package/dist/chunk-2XLX6PFR.js.map +1 -0
- package/dist/{chunk-CPCUPK4J.js → chunk-DWVRVPMN.js} +5 -12
- package/dist/chunk-DWVRVPMN.js.map +1 -0
- package/dist/chunk-ELZHIN6E.js +16 -0
- package/dist/chunk-ELZHIN6E.js.map +1 -0
- package/dist/chunk-H3GSGAX2.js +11 -0
- package/dist/chunk-H3GSGAX2.js.map +1 -0
- package/dist/{chunk-PWV3BMDA.js → chunk-MMKBDO5V.js} +11 -2
- package/dist/chunk-MMKBDO5V.js.map +1 -0
- package/dist/chunk-NUOQXAYR.js +14 -0
- package/dist/chunk-NUOQXAYR.js.map +1 -0
- package/dist/{chunk-WLFE5RUU.js → chunk-NW6UHNYX.js} +6 -6
- package/dist/chunk-NW6UHNYX.js.map +1 -0
- package/dist/chunk-O7REVMOC.js +38 -0
- package/dist/chunk-O7REVMOC.js.map +1 -0
- package/dist/chunk-QXFCZON7.js +16 -0
- package/dist/chunk-QXFCZON7.js.map +1 -0
- package/dist/{chunk-MT7VQXRA.js → chunk-SOCLCHJK.js} +228 -13
- package/dist/chunk-SOCLCHJK.js.map +1 -0
- package/dist/{chunk-3TDUIKVQ.js → chunk-SWWQ7X7B.js} +11 -8
- package/dist/chunk-SWWQ7X7B.js.map +1 -0
- package/dist/chunk-TDQGNNCV.js +5618 -0
- package/dist/{chunk-ASBZQAXV.js → chunk-USVNSL76.js} +221 -4339
- package/dist/index.d.ts +28 -5
- package/dist/index.js +7 -4
- package/dist/roles/build/nest/toolchain.js +11 -33
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/roles/test/nest/toolchain.js +3 -2
- package/dist/roles/test/nest/toolchain.js.map +1 -1
- package/dist/roles/test/react/toolchain.js +3 -2
- package/dist/roles/test/react/toolchain.js.map +1 -1
- package/dist/roles/test/setup/a11y.d.ts +45 -0
- package/dist/roles/test/setup/a11y.js +72 -0
- package/dist/roles/test/setup/a11y.js.map +1 -0
- package/dist/roles/test/setup/file-boundary-close.d.ts +2 -0
- package/dist/roles/test/setup/file-boundary-close.js +8 -0
- package/dist/roles/test/setup/file-boundary-close.js.map +1 -0
- package/dist/roles/test/setup/file-boundary.d.ts +2 -0
- package/dist/roles/test/setup/file-boundary.js +62 -0
- package/dist/roles/test/setup/file-boundary.js.map +1 -0
- package/dist/roles/test/setup/jest-parity.js +19 -2
- package/dist/roles/test/setup/jest-parity.js.map +1 -1
- package/dist/roles/test/setup/msw-lifecycle.js +2 -1
- package/dist/roles/test/setup/msw-lifecycle.js.map +1 -1
- package/dist/roles/test/setup/msw-server.js +2 -1
- package/dist/roles/test/setup/msw-server.js.map +1 -1
- package/dist/roles/test/setup/w3c.d.ts +75 -0
- package/dist/roles/test/setup/w3c.js +49 -0
- package/dist/roles/test/setup/w3c.js.map +1 -0
- package/dist/roles/test/setup/workspace-entry.js +3 -2
- package/dist/roles/test/setup/workspace-entry.js.map +1 -1
- package/dist/roles/test/shared-test-config.d.ts +13 -0
- package/dist/roles/test/shared-test-config.js +3 -2
- package/dist/roles/test/tools/msw.d.ts +1 -0
- package/dist/roles/test/tools/msw.js +3 -0
- package/dist/roles/test/tools/msw.js.map +1 -0
- package/dist/validate-JUMFX4ZF.js +169 -0
- package/docs/test-adoption.md +282 -5
- package/docs/using-sentinel.md +17 -1
- package/docs/validating-a-change.md +35 -2
- package/package.json +29 -4
- package/types/jest-global.d.ts +85 -0
- package/types/mock-extended.d.ts +52 -0
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ A large monorepo accumulates:
|
|
|
24
24
|
- **One source of truth for config** — every project just `extends @hublo/sentinel/...`; the actual rules live in one versioned place. Change a rule once, everyone gets it on the next version bump.
|
|
25
25
|
- **One source of truth for tooling dependencies** — a project depends on `@hublo/sentinel`, not on a scattered pile of eslint / vitest / plugin devDeps. Bump one version and the whole toolchain moves, atomically, tested in isolation first.
|
|
26
26
|
- **`--init` sets a module up** — the one command generates the stubs the first time (**adopt**), regenerates them after a change like a runner swap (**refresh**), and applies the workspace prep the module needs. Run it module by module to roll out gradually. (`--migrate`, for changing an already-initialized setup, is a reserved future verb.)
|
|
27
|
+
- **`--validate` proves the move did not change anything** — a verb of its own, because what separates it from `--init` is what SURVIVES the run: `--init` and `--migrate` write state that REMAINS, `--validate` may instrument the module it is proving provided nothing it writes stays. Run before a migration it records what the module does today; run after, it compares and refuses a run that lost a test or invented one. It composes with every type, and answers "not available yet" for the ones without a proof rather than calling a valid command wrong.
|
|
27
28
|
- **Move one module at a time** — installed per module, so you adopt at your pace; a module can adopt sentinel while its neighbour keeps the old setup. No big-bang.
|
|
28
29
|
- **Swap tools without touching projects** — change eslint → biome (or benchmark them) in one place; `--init` regenerates the stubs.
|
|
29
30
|
- **No silent drift** — the guard keeps every project's config converged on the source of truth.
|
package/dist/bin/sentinel.d.ts
CHANGED
package/dist/bin/sentinel.js
CHANGED
|
@@ -14,118 +14,33 @@ import {
|
|
|
14
14
|
dispatch,
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
|
-
resolve
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
resolve
|
|
18
|
+
} from "../chunk-USVNSL76.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveContext
|
|
21
|
+
} from "../chunk-TDQGNNCV.js";
|
|
22
|
+
import "../chunk-O7REVMOC.js";
|
|
23
|
+
import "../chunk-QXFCZON7.js";
|
|
20
24
|
import {
|
|
21
|
-
WORKSPACE_ROOT_MARKER,
|
|
22
25
|
ensureWorkspacePrep,
|
|
23
26
|
findWorkspaceRoot,
|
|
24
27
|
inspectWorkspacePrep,
|
|
25
|
-
moduleName,
|
|
26
28
|
readOwnVersion,
|
|
27
29
|
readProjectPackageJson
|
|
28
|
-
} from "../chunk-
|
|
30
|
+
} from "../chunk-NW6UHNYX.js";
|
|
29
31
|
|
|
30
32
|
// bin/sentinel.ts
|
|
31
33
|
import { program } from "commander";
|
|
32
34
|
|
|
33
|
-
// src/core/context.ts
|
|
34
|
-
import { existsSync } from "fs";
|
|
35
|
-
import { join as join2 } from "path";
|
|
36
|
-
|
|
37
|
-
// src/core/discover-modules.ts
|
|
38
|
-
import { execFileSync } from "child_process";
|
|
39
|
-
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
40
|
-
import { tmpdir } from "os";
|
|
41
|
-
import { join } from "path";
|
|
42
|
-
function runNx(cwd2, args) {
|
|
43
|
-
const nx = resolveBin(cwd2, "nx") ?? "nx";
|
|
44
|
-
try {
|
|
45
|
-
return execFileSync(nx, args, {
|
|
46
|
-
cwd: cwd2,
|
|
47
|
-
encoding: "utf8",
|
|
48
|
-
env: { ...process.env, NX_DAEMON: "false" }
|
|
49
|
-
});
|
|
50
|
-
} catch (error) {
|
|
51
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
-
throw new Error(
|
|
53
|
-
`sentinel: could not run nx (${message}). Is nx installed in this workspace, and are you at its root?`,
|
|
54
|
-
{ cause: error }
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function readGraph(cwd2) {
|
|
59
|
-
const dir = mkdtempSync(join(tmpdir(), "sentinel-nx-"));
|
|
60
|
-
const file = join(dir, "graph.json");
|
|
61
|
-
try {
|
|
62
|
-
runNx(cwd2, ["graph", "--file", file]);
|
|
63
|
-
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
64
|
-
const nodes = parsed.graph?.nodes;
|
|
65
|
-
if (!nodes || typeof nodes !== "object") {
|
|
66
|
-
throw new Error(
|
|
67
|
-
"sentinel: unexpected nx graph output (no graph.nodes); the installed nx version may be incompatible."
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
return Object.entries(nodes).map(([name, node]) => ({
|
|
71
|
-
name,
|
|
72
|
-
root: join(cwd2, node.data.root)
|
|
73
|
-
}));
|
|
74
|
-
} finally {
|
|
75
|
-
rmSync(dir, { recursive: true, force: true });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function discoverModules(cwd2, options = {}) {
|
|
79
|
-
const modules = readGraph(cwd2);
|
|
80
|
-
if (!options.affected) return modules;
|
|
81
|
-
const affected = new Set(
|
|
82
|
-
JSON.parse(runNx(cwd2, ["show", "projects", "--affected", "--json"]))
|
|
83
|
-
);
|
|
84
|
-
return modules.filter((module) => affected.has(module.name));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// src/core/context.ts
|
|
88
|
-
var MODULE_MARKERS = ["package.json", "project.json"];
|
|
89
|
-
function isModuleDir(cwd2) {
|
|
90
|
-
return MODULE_MARKERS.some((marker) => existsSync(join2(cwd2, marker)));
|
|
91
|
-
}
|
|
92
|
-
function resolveContext(cwd2, opts2) {
|
|
93
|
-
const atRoot = existsSync(join2(cwd2, WORKSPACE_ROOT_MARKER));
|
|
94
|
-
if (!atRoot && isModuleDir(cwd2)) {
|
|
95
|
-
if (opts2.module) {
|
|
96
|
-
throw new Error(
|
|
97
|
-
"You are in a module directory: drop --module (the context is the current module)."
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
if (opts2.ci) {
|
|
101
|
-
throw new Error("--ci selects the affected set from the workspace root; run it there.");
|
|
102
|
-
}
|
|
103
|
-
const name = moduleName(cwd2);
|
|
104
|
-
return { modules: [{ name, root: cwd2 }], scope: "cwd-module" };
|
|
105
|
-
}
|
|
106
|
-
if (atRoot) {
|
|
107
|
-
if (opts2.module) {
|
|
108
|
-
const found = discoverModules(cwd2).find((module) => module.name === opts2.module);
|
|
109
|
-
if (!found) throw new Error(`module "${opts2.module}" not found in the workspace.`);
|
|
110
|
-
return { modules: [found], scope: "named-module" };
|
|
111
|
-
}
|
|
112
|
-
if (opts2.ci) return { modules: discoverModules(cwd2, { affected: true }), scope: "affected" };
|
|
113
|
-
return { modules: discoverModules(cwd2), scope: "all" };
|
|
114
|
-
}
|
|
115
|
-
throw new Error(
|
|
116
|
-
`Run sentinel from a module directory or the workspace root (found neither ${MODULE_MARKERS.join("/")} nor ${WORKSPACE_ROOT_MARKER} here).`
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
35
|
// src/roles/build/declined.ts
|
|
121
|
-
import { existsSync
|
|
122
|
-
import { join
|
|
36
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
37
|
+
import { join } from "node:path";
|
|
123
38
|
var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
|
|
124
39
|
function buildsWithDeprecatedExecutor(cwd2) {
|
|
125
|
-
const projectJson =
|
|
126
|
-
if (!
|
|
40
|
+
const projectJson = join(cwd2, "project.json");
|
|
41
|
+
if (!existsSync(projectJson)) return false;
|
|
127
42
|
try {
|
|
128
|
-
const project = JSON.parse(
|
|
43
|
+
const project = JSON.parse(readFileSync(projectJson, "utf8"));
|
|
129
44
|
return project.targets?.build?.executor === DEPRECATED_NX_BUILDER;
|
|
130
45
|
} catch {
|
|
131
46
|
return false;
|
|
@@ -218,6 +133,32 @@ sentinel (${type}): ${asMessage(error)}
|
|
|
218
133
|
return worst;
|
|
219
134
|
}
|
|
220
135
|
|
|
136
|
+
// src/cli/run-validate.ts
|
|
137
|
+
async function runValidate(ctx) {
|
|
138
|
+
const { modules } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
|
|
139
|
+
const unsupported = ctx.targets.filter((target) => target !== "test");
|
|
140
|
+
if (unsupported.length > 0) {
|
|
141
|
+
const named = unsupported.map((target) => `--${target}`).join(", ");
|
|
142
|
+
process.stdout.write(
|
|
143
|
+
` \xB7 ${named}: not available yet. \`--validate\` answers \`--test\` today, by recording a suite before a migration and comparing it after. Nothing was checked for ${named}.
|
|
144
|
+
`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!ctx.targets.includes("test")) return 1;
|
|
148
|
+
const { validateModuleTests } = await import("../validate-JUMFX4ZF.js");
|
|
149
|
+
const outcomes = modules.flatMap(
|
|
150
|
+
(module) => validateModuleTests(module.name, module.root)
|
|
151
|
+
);
|
|
152
|
+
for (const outcome of outcomes) {
|
|
153
|
+
const suite = outcome.suite === "main" ? "" : ` [${outcome.suite}]`;
|
|
154
|
+
process.stdout.write(
|
|
155
|
+
` ${outcome.ok ? "\u2713" : "\u2717"} ${outcome.module}${suite} \u2014 ${outcome.message}
|
|
156
|
+
`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
221
162
|
// src/core/orchestrate.ts
|
|
222
163
|
async function analyse(params) {
|
|
223
164
|
const results = [];
|
|
@@ -645,13 +586,16 @@ program.name("sentinel").description("One CLI that guards code health: presets,
|
|
|
645
586
|
"before",
|
|
646
587
|
[
|
|
647
588
|
"A check composes: verb + type + location.",
|
|
648
|
-
" verb what to do: --run --inspect --report --status --init (--migrate: planned)",
|
|
589
|
+
" verb what to do: --run --inspect --report --status --init --validate (--migrate: planned)",
|
|
649
590
|
" type which check: --lint --typescript ... (omit = all types; or --all)",
|
|
650
591
|
" where run from a MODULE dir \u2192 that module; from the ROOT \u2192 --module <name>,",
|
|
651
592
|
" --ci (affected), or all modules. --init targets one module only.",
|
|
652
593
|
""
|
|
653
594
|
].join("\n")
|
|
654
|
-
).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option(
|
|
595
|
+
).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option(
|
|
596
|
+
"--validate",
|
|
597
|
+
"prove a migration kept the suite: record the reference, then compare against it"
|
|
598
|
+
).option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
|
|
655
599
|
"--module <name>",
|
|
656
600
|
"from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
|
|
657
601
|
).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
|
|
@@ -783,7 +727,7 @@ async function main() {
|
|
|
783
727
|
dryRun: Boolean(opts.dryRun),
|
|
784
728
|
fail: (message) => program.error(message)
|
|
785
729
|
};
|
|
786
|
-
const exitCode = verb === "init" ? await runInit(context) : await runVerb(context);
|
|
730
|
+
const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);
|
|
787
731
|
await exitWithoutTruncating(exitCode);
|
|
788
732
|
}
|
|
789
733
|
main().catch((error) => {
|
|
@@ -792,4 +736,3 @@ sentinel: ${asMessage2(error)}
|
|
|
792
736
|
`);
|
|
793
737
|
void exitWithoutTruncating(1);
|
|
794
738
|
});
|
|
795
|
-
//# sourceMappingURL=sentinel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles/test/setup/mock-extended.ts"],"sourcesContent":["/**\n * `jest-mock-extended`'s deep mocks, made answerable to the question Vitest's `spyOn` asks.\n *\n * A deep mock is a Proxy that invents a property the first time something READS it. Nothing exists\n * until then, so the object reports itself empty:\n *\n * const provider = mockDeep<InstitutionProvider>()\n * 'getAdminFirstAndLastNames' in provider // false\n * Object.getOwnPropertyDescriptor(provider, 'getAdmin...') // undefined\n * typeof provider.getAdminFirstAndLastNames // 'function', and now it exists\n *\n * jest's `spyOn` reads the property, so the Proxy created it and the spy worked. Vitest's asks the\n * object whether it HAS the property first, gets no for both questions, and throws\n * `The property \"getAdminFirstAndLastNames\" is not defined on the object.` The file then reports no\n * test at all, so its names simply go missing rather than failing.\n *\n * Measured on `apps/cloud/shift`: one such spy took 37 of its 108 tests away. The repo has 128 of\n * them across 29 files, mostly in `apps/nest/microservices` and\n * `apps/nest/backends-for-frontends`.\n *\n * ## Why it is fixed here and not in the 128 test files\n *\n * Because it is a difference between two runners, not something 29 suites each got wrong. A\n * codemod rewriting every site would put a migration artefact in front of every reader of those\n * files forever, and teams would carry it. One adapter keeps the test files exactly as they are.\n *\n * The two traps answer by doing what jest's `spyOn` did: read the property once, then answer. The\n * read is the Proxy's own documented way of materialising it, so nothing here reimplements the\n * mock, it only asks the question in the form the underlying object understands.\n *\n * `then` is never materialised. A deep mock that suddenly HAS a `then` is a thenable, and awaiting\n * it, or returning it from an async function, would hang on a promise nothing resolves.\n */\nimport * as mockExtended from 'vitest-mock-extended'\n\n/**\n * Keys that must stay absent however they are asked for.\n *\n * Not an arbitrary list: every one of them is a PROTOCOL PROBE, a property a framework reads to\n * decide what KIND of value it is holding. A deep mock invents whatever it is asked for, so it\n * answers yes to every probe, and each yes is a lie about what it is.\n *\n * then a thenable. Awaiting the mock, or returning it from an async function,\n * hangs on a promise nothing resolves.\n * asymmetricMatch an asymmetric matcher. Measured: `expect(p).rejects.toThrow(deepMock)`\n * answers `expected error to match asymmetric matcher` instead of comparing\n * against the class, so the assertion stops testing what it says.\n * $$typeof a React element. Serializers branch on it and read fields that are not\n * there.\n * nodeType a DOM node, which testing-library and serializers both branch on.\n *\n * Symbols never reach here: the materialiser returns early for a non-string key, so `Symbol.\n * iterator` and friends are already safe.\n */\nconst NEVER_MATERIALISED = new Set(['then', 'asymmetricMatch', '$$typeof', 'nodeType'])\n\nfunction materialise(target: object, key: PropertyKey): void {\n if (typeof key !== 'string' || NEVER_MATERIALISED.has(key)) return\n // The read IS the creation: that is how the deep mock's own Proxy works.\n void (target as Record<string, unknown>)[key]\n}\n\n/**\n * Wrap a deep mock so that `in` and `getOwnPropertyDescriptor` see what a read would create.\n *\n * Only the object handed back is wrapped, not what it returns: a nested spy would need its own\n * wrapper, and the corpus has none. Wrapping every read would also mean a new wrapper per access\n * unless they were cached, and an identity that changes between two reads is a worse bug than the\n * one being fixed.\n */\n/**\n * One proxy per underlying mock, so identity is stable.\n *\n * A suite holds on to `db.timeSlot` and compares it later, or passes it to `toHaveBeenCalledWith`.\n * Handing back a fresh proxy on every read would make the same mock unequal to itself.\n */\nconst wrappers = new WeakMap<object, object>()\n\nfunction answeringProxy<T extends object>(mock: T): T {\n return new Proxy(mock, {\n /*\n * A NESTED mock has to answer the same questions as the top-level one.\n *\n * The adapter used to wrap only what `mockDeep()` returned, and the level below it was a raw\n * mock again. Measured: `'deleteMany' in db.timeSlot` answered false and its descriptor was\n * absent, so `vi.spyOn(db.timeSlot, 'deleteMany')` failed with\n * `The property \"deleteMany\" is not defined on the function` — the exact error\n * `apps/nest/microservices/hublo-pool` reports, and the exact failure this adapter was written\n * to remove, one level down.\n *\n * Read from the TARGET, never with this proxy as the receiver: forwarding the receiver changes\n * what the deep mock hands back.\n */\n get(target, key) {\n if (typeof key === 'string' && NEVER_MATERIALISED.has(key)) return undefined\n\n const value = Reflect.get(target, key) as unknown\n if (value === null) return value\n if (typeof value !== 'object' && typeof value !== 'function') return value\n\n /*\n * A Proxy invariant, not a preference: for a property that is non-configurable AND\n * non-writable, `get` MUST hand back the target's own value. Wrapping one throws\n * `'get' on proxy: property 'mock' is a read-only and non-configurable data property on the\n * proxy target but the proxy did not return its actual value`, which is what a mock's own\n * `mock` record is.\n */\n const descriptor = Reflect.getOwnPropertyDescriptor(target, key)\n if (\n descriptor !== undefined &&\n descriptor.configurable === false &&\n descriptor.writable === false\n ) {\n return value\n }\n\n const existing = wrappers.get(value as object)\n if (existing !== undefined) return existing\n\n const wrapper = answeringProxy(value as object)\n wrappers.set(value as object, wrapper)\n return wrapper\n },\n has(target, key) {\n if (Reflect.has(target, key)) return true\n materialise(target, key)\n return Reflect.has(target, key)\n },\n getOwnPropertyDescriptor(target, key) {\n const existing = Reflect.getOwnPropertyDescriptor(target, key)\n if (existing !== undefined) return existing\n materialise(target, key)\n return Reflect.getOwnPropertyDescriptor(target, key)\n },\n })\n}\n\n/*\n * Everything the fork exports, so a module importing `jest-mock-extended` still gets `mockReset`,\n * `mockClear`, `anyString`, the matchers and the types. The two names below are declared after it:\n * an explicit export wins over a star export, which is what lets this file override exactly two\n * functions and pass the rest through untouched.\n */\nexport * from 'vitest-mock-extended'\n\n/*\n * And the same names again, one by one.\n *\n * `export *` from a CJS package forwards only what a static read of that package can SEE.\n * `vitest-mock-extended` ships CommonJS and assembles part of its exports at runtime, so the star\n * carries the types and loses several of the functions. A spec importing one of them then reads\n * `undefined` and dies at the call, naming the binding and nothing else. Measured on\n * `apps/nest/microservices/mission`:\n *\n * TypeError: (0 , __vite_ssr_import_3__.mockReset) is not a function\n *\n * It is the same shape as the automock namespace built from a static export list, which\n * `server.deps.inline` exists to answer; here the file doing the re-export is ours, so it can\n * simply say the names out loud. A name that ever disappears from the fork breaks the build\n * instead of a suite, which is the better end of that trade.\n */\nexport {\n any,\n anyArray,\n anyBoolean,\n anyFunction,\n anyMap,\n anyNumber,\n anyObject,\n anySet,\n anyString,\n anySymbol,\n arrayIncludes,\n calledWithFn,\n captor,\n isA,\n isMockObject,\n mapHas,\n matches,\n mockClear,\n mocked,\n mockedFn,\n mockFn,\n mockReset,\n notEmpty,\n notNull,\n notUndefined,\n objectContainsKey,\n} from 'vitest-mock-extended'\n\n/**\n * Every deep mock handed out, so `vi.resetAllMocks()` can reach them.\n *\n * ⚠️ Kept on `globalThis`, and that is not laziness. This file is reached by TWO specifiers: the\n * setup imports it by its path, and a spec imports `jest-mock-extended`, which the preset aliases\n * here. Vite resolves those to two ids and instantiates the module twice, so a registry held in\n * module scope would be filled by one instance and read by the other, and the reset would find\n * nothing. It is the same dual-identity trap the axios alias exists to close, one layer up.\n *\n * ⚠️ It does not reach them on its own, and that is a silent divergence. jest's\n * `jest.resetAllMocks()` reset these because `jest-mock-extended` built them with `jest.fn()`, so\n * they sat in jest's own registry. `vitest-mock-extended` builds them its own way, so\n * `vi.resetAllMocks()` walks past them and their call history survives into the next test.\n *\n * Measured on `apps/nest/microservices/activity`, whose suite does exactly what jest expected:\n *\n * beforeEach(() => mocked.findEvents.mockResolvedValue([]))\n * afterEach(() => vi.resetAllMocks())\n *\n * Eleven tests asserting `toHaveBeenCalledTimes(0)` saw the call left by the test before them.\n * Each one PASSES on its own and fails right after its neighbour, which is the signature of\n * leakage rather than of a broken assertion.\n *\n * A plain Set: a test file gets its own module instance, so the registry lives and dies with it.\n */\nconst REGISTRY = Symbol.for('@hublo/sentinel/deep-mocks')\nconst handedOut: Set<object> = ((globalThis as Record<symbol, unknown>)[REGISTRY] ??=\n new Set<object>()) as Set<object>\n\n/** Reset every deep mock this adapter created, the way jest's registry did. */\nexport function resetDeepMocks(): void {\n for (const created of handedOut) mockExtended.mockReset(created as never)\n}\n\n/** Clear their calls without touching their implementations. */\nexport function clearDeepMocks(): void {\n for (const created of handedOut) mockExtended.mockClear(created as never)\n}\n\nexport const mock = ((...args: unknown[]) => {\n const created = (mockExtended.mock as (...rest: unknown[]) => object)(...args)\n handedOut.add(created)\n return answeringProxy(created)\n}) as unknown as typeof mockExtended.mock\n\nexport const mockDeep = ((...args: unknown[]) => {\n const created = (mockExtended.mockDeep as (...rest: unknown[]) => object)(...args)\n handedOut.add(created)\n return answeringProxy(created)\n}) as unknown as typeof mockExtended.mockDeep\n"],"mappings":";AAiCA,YAAY,kBAAkB;AA8G9B,cAAc;AAkBd;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAAC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAtIP,IAAM,qBAAqB,oBAAI,IAAI,CAAC,QAAQ,mBAAmB,YAAY,UAAU,CAAC;AAEtF,SAAS,YAAY,QAAgB,KAAwB;AAC3D,MAAI,OAAO,QAAQ,YAAY,mBAAmB,IAAI,GAAG,EAAG;AAE5D,OAAM,OAAmC,GAAG;AAC9C;AAgBA,IAAM,WAAW,oBAAI,QAAwB;AAE7C,SAAS,eAAiCC,OAAY;AACpD,SAAO,IAAI,MAAMA,OAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAcrB,IAAI,QAAQ,KAAK;AACf,UAAI,OAAO,QAAQ,YAAY,mBAAmB,IAAI,GAAG,EAAG,QAAO;AAEnE,YAAM,QAAQ,QAAQ,IAAI,QAAQ,GAAG;AACrC,UAAI,UAAU,KAAM,QAAO;AAC3B,UAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAY,QAAO;AASrE,YAAM,aAAa,QAAQ,yBAAyB,QAAQ,GAAG;AAC/D,UACE,eAAe,UACf,WAAW,iBAAiB,SAC5B,WAAW,aAAa,OACxB;AACA,eAAO;AAAA,MACT;AAEA,YAAM,WAAW,SAAS,IAAI,KAAe;AAC7C,UAAI,aAAa,OAAW,QAAO;AAEnC,YAAM,UAAU,eAAe,KAAe;AAC9C,eAAS,IAAI,OAAiB,OAAO;AACrC,aAAO;AAAA,IACT;AAAA,IACA,IAAI,QAAQ,KAAK;AACf,UAAI,QAAQ,IAAI,QAAQ,GAAG,EAAG,QAAO;AACrC,kBAAY,QAAQ,GAAG;AACvB,aAAO,QAAQ,IAAI,QAAQ,GAAG;AAAA,IAChC;AAAA,IACA,yBAAyB,QAAQ,KAAK;AACpC,YAAM,WAAW,QAAQ,yBAAyB,QAAQ,GAAG;AAC7D,UAAI,aAAa,OAAW,QAAO;AACnC,kBAAY,QAAQ,GAAG;AACvB,aAAO,QAAQ,yBAAyB,QAAQ,GAAG;AAAA,IACrD;AAAA,EACF,CAAC;AACH;AAgFA,IAAM,WAAW,uBAAO,IAAI,4BAA4B;AACxD,IAAM,YAA2B,WAAuC,QAAQ,MAC9E,oBAAI,IAAY;AAGX,SAAS,iBAAuB;AACrC,aAAW,WAAW,UAAW,CAAa,uBAAU,OAAgB;AAC1E;AAGO,SAAS,iBAAuB;AACrC,aAAW,WAAW,UAAW,CAAa,uBAAU,OAAgB;AAC1E;AAEO,IAAMA,SAAQ,IAAI,SAAoB;AAC3C,QAAM,UAAwB,kBAAwC,GAAG,IAAI;AAC7E,YAAU,IAAI,OAAO;AACrB,SAAO,eAAe,OAAO;AAC/B;AAEO,IAAMC,aAAY,IAAI,SAAoB;AAC/C,QAAM,UAAwB,sBAA4C,GAAG,IAAI;AACjF,YAAU,IAAI,OAAO;AACrB,SAAO,eAAe,OAAO;AAC/B;","names":["mockClear","mockReset","mock","mockDeep"]}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fromModule
|
|
3
|
+
} from "./chunk-ELZHIN6E.js";
|
|
1
4
|
import {
|
|
2
5
|
findWorkspaceRoot
|
|
3
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-NW6UHNYX.js";
|
|
4
7
|
|
|
5
8
|
// src/roles/test/setup/workspace.ts
|
|
6
|
-
import { createRequire } from "module";
|
|
7
|
-
import { join } from "path";
|
|
8
9
|
function isMissingModule(error) {
|
|
9
10
|
return error?.code === "ERR_MODULE_NOT_FOUND";
|
|
10
11
|
}
|
|
@@ -18,14 +19,6 @@ async function optional(specifier, use) {
|
|
|
18
19
|
if (!isMissingModule(error)) throw error;
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
|
-
function fromModule(specifier) {
|
|
22
|
-
try {
|
|
23
|
-
const require2 = createRequire(join(process.cwd(), "noop.cjs"));
|
|
24
|
-
return require2(specifier);
|
|
25
|
-
} catch {
|
|
26
|
-
return void 0;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
22
|
async function loadEnvironment() {
|
|
30
23
|
await optional("dotenv-flow", (module) => {
|
|
31
24
|
const flow = module;
|
|
@@ -67,4 +60,4 @@ export {
|
|
|
67
60
|
installWorkspaceSetup,
|
|
68
61
|
pinWorkspaceTimezone
|
|
69
62
|
};
|
|
70
|
-
//# sourceMappingURL=chunk-
|
|
63
|
+
//# sourceMappingURL=chunk-DWVRVPMN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles/test/setup/workspace.ts"],"sourcesContent":["/**\n * The rest of what a migrated suite needs before its tests, loaded DEFENSIVELY.\n *\n * ## Why this is in sentinel and not in the repo\n *\n * Because it is config, and an app must depend on its own config and nothing outside it. The first\n * version of the generated config referenced `'../../../../vitest.setup.mts'`, a relative path\n * climbing four levels to the workspace root — which is exactly the external link this whole\n * migration exists to remove. A module that reaches outside itself cannot move on its own.\n *\n * ## Why loading is conditional, and why that is the point rather than a precaution\n *\n * Unlike the msw lifecycle beside it, nothing here reacts to the RUNNER. `dotenv-flow` and luxon's\n * zone would behave identically under jest, Vitest or `node:test`; what they react to is the REPO,\n * which chose those two packages. So the conditional load is the statement of ownership: sentinel\n * offers the behaviour, and the repo that installed the package decides whether it happens.\n *\n * That is also what keeps sentinel installable elsewhere. Neither package can be a hard dependency,\n * so each is loaded only if present and skipped in silence if not. Elsewhere this file does nothing;\n * here both are present and the behaviour is the one the jest setup had.\n *\n * Silence is the right answer for an absent package and the wrong one for a broken package, so a\n * load that fails for any other reason is re-thrown rather than swallowed.\n */\n\nimport { findWorkspaceRoot } from '../../../core/workspace-prep.js'\n\nimport { fromModule } from './module-copy.js'\n\n/** Was this module absent, as opposed to present and broken? */\nfunction isMissingModule(error: unknown): boolean {\n return (error as { code?: string } | null)?.code === 'ERR_MODULE_NOT_FOUND'\n}\n\n/**\n * Optional by construction: `import()` inside a try, so a missing package is a no-op rather than a\n * failure at load time. A static import would make every one of them a hard dependency.\n */\nasync function optional(specifier: string, use: (module: unknown) => void): Promise<void> {\n try {\n use(await import(/* @vite-ignore */ specifier))\n } catch (error) {\n if (!isMissingModule(error)) throw error\n }\n}\n\n/**\n * `dotenv-flow`, which the root jest setup loaded first.\n *\n * Vite's own `.env` handling does NOT replace it, which was checked before writing this. Measured on\n * Vitest 4 against a fixture holding `.env` and `.env.test`: the cascade is read and `MODE` is\n * `test`, but only `VITE_`-prefixed values are exposed, and only on `import.meta.env`. `process.env`\n * comes back untouched, and the code under test reads unprefixed `process.env`.\n *\n * `purge_dotenv` is carried across: it clears variables a previous load left behind, and without it\n * a value from one run leaks into the next. Not a detail in a suite that asserts on URLs.\n */\nasync function loadEnvironment(): Promise<void> {\n // A plain dependency of sentinel now: it mutates `process.env`, which is global, so WHICH copy\n // runs does not matter. It is the other half of replacing the root setup, and the root is going\n // away (Héla, 2026-09-21: \"mon but est de supprimer la config root\").\n await optional('dotenv-flow', (module) => {\n const flow = module as {\n config?: (options: Record<string, unknown>) => void\n default?: unknown\n }\n const config =\n flow.config ?? (flow.default as { config?: (o: Record<string, unknown>) => void })?.config\n /*\n * From the WORKSPACE ROOT, not from the module.\n *\n * dotenv-flow reads its cascade from the current directory, and the two runners do not share\n * one: jest ran from the repo root, where `.env`, `.env.test` and `.env.token` live, while\n * `sentinel --run --test` runs from the module, where there is nothing to read. Measured on\n * `libs/cloud/events-notifications`: from the module, a service call waits for an endpoint its\n * env never named and the test dies on a 5 s timeout; from the root, it answers in 200 ms.\n *\n * The root is found rather than configured: the setup cannot be handed a value, since a module\n * that overrides `test.env` would replace whatever the preset injected there.\n */\n const workspaceRoot = findWorkspaceRoot(process.cwd())\n config?.({\n silent: true,\n purge_dotenv: true,\n ...(workspaceRoot === undefined ? {} : { path: workspaceRoot }),\n })\n })\n}\n\n/**\n * Luxon's default zone, carried as luxon's own setting and NOT translated to `process.env.TZ`.\n *\n * They are different instructions: one configures luxon, the other the whole process, `Date` and\n * `Intl` included. Swapping them would change what a suite does while claiming to migrate it, and\n * timezone is not a detail here — a machine's zone accounted for a large part of 345 local failures\n * on `host-admin` that did not exist in CI.\n */\nasync function pinLuxonZone(): Promise<void> {\n // The module's copy, deliberately: `Settings` is per-copy state, so sentinel's own luxon is the\n // one package this setup must NOT configure.\n /*\n * Through the RUNNER's resolver first, which is what the tests use. `fromModule` goes through\n * `require`, and a package shipping both builds hands it the CJS one while Vite hands the tests\n * the ESM one. Two copies, two `Settings`, and the pin lands on the one nobody reads: measured on\n * `libs/cloud/events-notifications`, whose suite ran in `Europe/Paris` and compared timestamps\n * two hours apart. The preset aliases luxon to one path so both routes agree.\n */\n let pinned = false\n await optional('luxon', (module) => {\n const settings = (module as { Settings?: { defaultZone: string } }).Settings\n if (settings === undefined) return\n settings.defaultZone = 'utc'\n pinned = true\n })\n if (pinned) return\n\n const own = fromModule('luxon') as { Settings?: { defaultZone: string } } | undefined\n if (own?.Settings !== undefined) {\n own.Settings.defaultZone = 'utc'\n return\n }\n\n // Said out loud rather than skipped: a suite whose dates silently run in the machine's zone is\n // the kind of failure that gets blamed on the migration months later.\n process.stderr.write(\n 'sentinel (test): luxon was not found in this module, so the UTC default zone is NOT pinned. ' +\n 'Dates will follow the machine zone, as they did not under jest.\\n',\n )\n}\n\n/**\n * Everything the workspace needs beyond the msw lifecycle.\n *\n * NOT included: the `jest.mock('dynamoose')` and `jest.mock('@opentelemetry/exporter-metrics-otlp-grpc')`\n * calls the jest setup made. A module mock is a per-suite decision that `vi.mock` must make in the\n * file that needs it, and hoisting it into a shared setup is what makes a test pass for a reason\n * nobody can see. The migration reports them instead, so the module that relies on one declares it.\n */\nexport async function installWorkspaceSetup(): Promise<void> {\n await loadEnvironment()\n}\n\n/**\n * Luxon's default zone, split out because it is not in the same class as the rest.\n *\n * ⚠️ These two were one function until 23/09 and moving them together broke three modules.\n *\n * The `.env` cascade is needed by any suite whose code reads `process.env`, whatever setup its\n * jest config named: under jest those modules got it from a `globalSetup` running in the main\n * process, and Vitest's workers do not inherit it the same way. Removing it cost\n * `libs/front/logic` 23 tests and `libs/front/api` 44, all with `missing env var\n * NEXT_PUBLIC_MONOREPO_BASE_URL`.\n *\n * The UTC pin is a decision ONE file at the workspace root made, and only the modules naming that\n * file ever had it. So it is conditional and the cascade is not.\n */\nexport async function pinWorkspaceTimezone(): Promise<void> {\n await pinLuxonZone()\n}\n"],"mappings":";;;;;;;;AA8BA,SAAS,gBAAgB,OAAyB;AAChD,SAAQ,OAAoC,SAAS;AACvD;AAMA,eAAe,SAAS,WAAmB,KAA+C;AACxF,MAAI;AACF,QAAI,MAAM;AAAA;AAAA,MAA0B;AAAA,KAAU;AAAA,EAChD,SAAS,OAAO;AACd,QAAI,CAAC,gBAAgB,KAAK,EAAG,OAAM;AAAA,EACrC;AACF;AAaA,eAAe,kBAAiC;AAI9C,QAAM,SAAS,eAAe,CAAC,WAAW;AACxC,UAAM,OAAO;AAIb,UAAM,SACJ,KAAK,UAAW,KAAK,SAA+D;AAatF,UAAM,gBAAgB,kBAAkB,QAAQ,IAAI,CAAC;AACrD,aAAS;AAAA,MACP,QAAQ;AAAA,MACR,cAAc;AAAA,MACd,GAAI,kBAAkB,SAAY,CAAC,IAAI,EAAE,MAAM,cAAc;AAAA,IAC/D,CAAC;AAAA,EACH,CAAC;AACH;AAUA,eAAe,eAA8B;AAU3C,MAAI,SAAS;AACb,QAAM,SAAS,SAAS,CAAC,WAAW;AAClC,UAAM,WAAY,OAAkD;AACpE,QAAI,aAAa,OAAW;AAC5B,aAAS,cAAc;AACvB,aAAS;AAAA,EACX,CAAC;AACD,MAAI,OAAQ;AAEZ,QAAM,MAAM,WAAW,OAAO;AAC9B,MAAI,KAAK,aAAa,QAAW;AAC/B,QAAI,SAAS,cAAc;AAC3B;AAAA,EACF;AAIA,UAAQ,OAAO;AAAA,IACb;AAAA,EAEF;AACF;AAUA,eAAsB,wBAAuC;AAC3D,QAAM,gBAAgB;AACxB;AAgBA,eAAsB,uBAAsC;AAC1D,QAAM,aAAa;AACrB;","names":[]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/roles/test/setup/module-copy.ts
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function fromModule(specifier) {
|
|
5
|
+
try {
|
|
6
|
+
const require2 = createRequire(join(process.cwd(), "noop.cjs"));
|
|
7
|
+
return require2(specifier);
|
|
8
|
+
} catch {
|
|
9
|
+
return void 0;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
fromModule
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=chunk-ELZHIN6E.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles/test/setup/module-copy.ts"],"sourcesContent":["/**\n * Reaching the MODULE's copy of a package rather than sentinel's.\n *\n * Under pnpm, an import written inside sentinel resolves from sentinel's own directory, so it sees\n * only sentinel's dependencies. That is right for anything whose effect is GLOBAL, and wrong for\n * anything whose effect is per-copy. Measured 2026-09-21: neither `luxon` nor `dotenv-flow`\n * resolves from sentinel while both resolve from the module under test, so a silent optional\n * import skipped both and a migrated suite lost the `.env` cascade and the UTC pin without a word.\n *\n * Extracted from `./workspace.js`, which found the problem, so the second caller\n * (`./file-boundary.js`, reaching for the module's Testing Library) does not rediscover it.\n */\nimport { createRequire } from 'node:module'\nimport { join } from 'node:path'\n\n/** The module's own copy of `specifier`, or `undefined` when it does not have one. */\nexport function fromModule(specifier: string): unknown | undefined {\n try {\n const require = createRequire(join(process.cwd(), 'noop.cjs'))\n return require(specifier)\n } catch {\n return undefined\n }\n}\n"],"mappings":";AAYA,SAAS,qBAAqB;AAC9B,SAAS,YAAY;AAGd,SAAS,WAAW,WAAwC;AACjE,MAAI;AACF,UAAMA,WAAU,cAAc,KAAK,QAAQ,IAAI,GAAG,UAAU,CAAC;AAC7D,WAAOA,SAAQ,SAAS;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":["require"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/roles/test/setup/file-boundary-state.ts
|
|
2
|
+
var CURRENT_FILE_MOCKS = /* @__PURE__ */ Symbol.for("@hublo/sentinel/test/current-file-mocks");
|
|
3
|
+
var PHASE = /* @__PURE__ */ Symbol.for("@hublo/sentinel/test/boundary-phase");
|
|
4
|
+
var SHARED_WORKER = /* @__PURE__ */ Symbol.for("@hublo/sentinel/test/shared-worker");
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
CURRENT_FILE_MOCKS,
|
|
8
|
+
PHASE,
|
|
9
|
+
SHARED_WORKER
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=chunk-H3GSGAX2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles/test/setup/file-boundary-state.ts"],"sourcesContent":["/**\n * The three keys `./file-boundary.js` and `./file-boundary-close.js` share.\n *\n * They live on `globalThis` rather than in this module's scope for one reason: with isolation off\n * a module is evaluated once per worker, but with isolation ON it is re-evaluated per file, and\n * the two halves of the boundary must find the same state in both cases. `Symbol.for` keeps that\n * true even if pnpm's layout hands the two halves different copies of this file.\n */\n\n/**\n * Mocks the test file currently running built, and the only thing the global reset APIs act on.\n *\n * Mocks a SETUP file built are not recorded at all rather than recorded separately: nothing needs\n * to iterate them, and a setup file runs per test file, so keeping them grew without bound.\n */\nexport const CURRENT_FILE_MOCKS = Symbol.for('@hublo/sentinel/test/current-file-mocks')\n\n/** `'infrastructure'` while the setup files run, `'file'` from the test file onwards. */\nexport const PHASE = Symbol.for('@hublo/sentinel/test/boundary-phase')\n\n/**\n * Set by `./file-boundary.js`, which the preset installs only when a suite shares a worker.\n *\n * Read by anything whose lifecycle differs between the two, msw's `close()` being the first: its\n * boundary is the worker, and only per-file isolation ever made the worker and the file the same\n * thing.\n */\nexport const SHARED_WORKER = Symbol.for('@hublo/sentinel/test/shared-worker')\n"],"mappings":";AAeO,IAAM,qBAAqB,uBAAO,IAAI,yCAAyC;AAG/E,IAAM,QAAQ,uBAAO,IAAI,qCAAqC;AAS9D,IAAM,gBAAgB,uBAAO,IAAI,oCAAoC;","names":[]}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SHARED_WORKER
|
|
3
|
+
} from "./chunk-H3GSGAX2.js";
|
|
4
|
+
|
|
1
5
|
// src/roles/test/setup/msw.ts
|
|
2
6
|
import { setupServer } from "msw/node";
|
|
3
7
|
var server = setupServer();
|
|
@@ -5,11 +9,16 @@ var LISTEN_OPTIONS = { onUnhandledRequest: "error" };
|
|
|
5
9
|
function installMswLifecycle(hooks) {
|
|
6
10
|
server.listen(LISTEN_OPTIONS);
|
|
7
11
|
hooks.beforeAll(() => server.listen(LISTEN_OPTIONS));
|
|
8
|
-
hooks.afterAll(() =>
|
|
12
|
+
hooks.afterAll(() => {
|
|
13
|
+
if (!sharesAWorker()) server.close();
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function sharesAWorker() {
|
|
17
|
+
return globalThis[SHARED_WORKER] === true;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
export {
|
|
12
21
|
server,
|
|
13
22
|
installMswLifecycle
|
|
14
23
|
};
|
|
15
|
-
//# sourceMappingURL=chunk-
|
|
24
|
+
//# sourceMappingURL=chunk-MMKBDO5V.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles/test/setup/msw.ts"],"sourcesContent":["/**\n * The HTTP interception a test suite runs against, owned here rather than by each module.\n *\n * ## Why sentinel ships msw at all\n *\n * The goal is that a future migration is ONE version bump of sentinel. Today's migration is\n * painful for the opposite reason: the runner and everything around it are declared in 133 places,\n * so moving them means moving 133 modules. A tool that sits between the runner and the tests, as\n * msw does, has to travel with the runner or it becomes the next thing nobody can move.\n *\n * It also puts the compatibility question where someone can answer it. When Vitest moves, msw\n * either still works with it or does not, and that is a question for the tooling team with one\n * test suite, not for thirty teams discovering it separately.\n *\n * ## Why version 1.3.3, which is not the newest\n *\n * Measured across the repo: 523 test files use the msw 1 API (`rest.get`), and NOT ONE uses the\n * msw 2 API (`http.get`). Shipping msw 2 would break all 523 on the day they migrate, turning one\n * migration into two. Shipping 1.3.3 keeps them working, and the eventual 1 to 2 move becomes\n * OURS: studied once, then automated or documented, which is the whole point of owning it.\n *\n * The one module on msw 2 is `apps/front/hr-management`, which is Svelte, and the test role defers\n * Svelte for its own reasons.\n */\nimport { setupServer } from 'msw/node'\n\nimport { SHARED_WORKER } from './file-boundary-state.js'\n\n/**\n * No default handlers, deliberately.\n *\n * The repo's own server had `defaultHandlers = []` too, so nothing is lost. A handler here would\n * be a Hublo endpoint inside a package meant to be usable by anyone, which is the line between\n * what sentinel owns (the config) and what an app owns (what it mocks).\n *\n * A suite adds its own with `server.use(...)`, which is how all 600 files that touch this already\n * work.\n */\nexport const server = setupServer()\n\n/** The options the repo's own setup passed, kept in one place since two calls now use them. */\nconst LISTEN_OPTIONS = { onUnhandledRequest: 'error' } as const\n\n/**\n * The lifecycle, as the root jest setup had it, plus ONE deviation the runners forced.\n *\n * `onUnhandledRequest: 'error'` is carried over rather than softened: a test that reaches a real\n * network is a test whose result depends on something outside the repo, and it should say so\n * loudly. The repo already ran this way, so nothing changes for a migrating suite.\n *\n * Registered through the globals Vitest exposes, because the preset sets `globals: true` and a\n * setup file that imported them would need them resolvable from wherever it is loaded.\n *\n * ## Why `listen()` also runs here, at module scope\n *\n * msw intercepts by REPLACING `request` and `get` on the `http` and `https` modules. Under jest,\n * `import * as https from 'https'` compiled to `require('https')`, so the module object was read\n * at call time and a patch applied later was seen. Under Vitest the same line is a real ESM\n * namespace import of a CJS builtin, and Node builds that namespace ONCE, from the exports as they\n * stand at first import. A patch applied afterwards never reaches it.\n *\n * `beforeAll` runs after the test file and everything it imports have been evaluated, so the\n * namespace is already frozen by then: the provider keeps calling the ORIGINAL `https.request` and\n * the request leaves the machine. Measured on `libs/cloud/hermes`: `send-push` reached the REAL\n * OneSignal API, which answered `400 Failed to parse app_id from request`, a message that exists\n * nowhere in this repo. msw never saw the call, so `onUnhandledRequest: 'error'` never fired\n * either, and the only visible symptom was `expected \"vi.fn()\" to be called 50 times, but got 0`.\n *\n * A setup file is evaluated BEFORE the test module graph, so patching here happens before the\n * namespace is built. Same file, same suite, 0 of 7 to 7 of 7.\n *\n * The `beforeAll` call is kept as well: it is what restores interception for a suite that closed\n * the server itself, and msw's own `listen()` is safe to call twice.\n */\nexport function installMswLifecycle(hooks: {\n beforeAll: (fn: () => void) => void\n afterEach: (fn: () => void) => void\n afterAll: (fn: () => void) => void\n}): void {\n server.listen(LISTEN_OPTIONS)\n hooks.beforeAll(() => server.listen(LISTEN_OPTIONS))\n\n /*\n * ⚠️ `close()` is per WORKER, not per file, and only isolation ever made those the same thing.\n *\n * Closing disposes the interceptors msw installed on `http`, `https`, `fetch` and `XMLHttpRequest`\n * and drops the registry it keeps on a global symbol. With a file-per-registry that was free: the\n * next file rebuilt everything from nothing. Sharing a worker, the next `listen()` finds a\n * disposed registry that is still THERE, so interception silently stops and the suite reads\n * whatever the previous file's handlers last said.\n *\n * Measured on `front-components` with `isolate: false`: one test, `useFeatureFlags`, and it took\n * a while to name because a global sweep was hiding it by deleting msw's four symbols 210 times\n * a run. Removing the per-file close instead: 230 files, 1418 tests, green, and green again\n * across four shuffled file orders.\n *\n * Not closing at all is safe: the worker exits when its files are done, and the process takes the\n * patched builtins with it. A module that wrote its own `afterAll(() => server.close())` before\n * adopting this needs the same line removed, which `--validate` surfaces as the suite failing.\n */\n hooks.afterAll(() => {\n if (!sharesAWorker()) server.close()\n })\n\n /*\n * `server.resetHandlers()` is NOT called here, and that is a transcription and not a judgement.\n *\n * The root jest setup has the line commented out. Enabling it would be an improvement in the\n * abstract — a handler one test adds leaks into the next, and the failure then appears in\n * whichever test happens to run after, which is the hardest kind to trace. But some suite may\n * depend on that persistence today, and a migration is the wrong moment to find out.\n *\n * So the behaviour is carried across exactly, and the question is reported rather than settled\n * here: it belongs to whoever owns those suites, as its own reviewed change.\n */\n}\n\n/** Whether the files of this suite share a worker, which `./file-boundary.js` is what declares. */\nfunction sharesAWorker(): boolean {\n return (globalThis as Record<symbol, unknown>)[SHARED_WORKER] === true\n}\n"],"mappings":";;;;;AAwBA,SAAS,mBAAmB;AAcrB,IAAM,SAAS,YAAY;AAGlC,IAAM,iBAAiB,EAAE,oBAAoB,QAAQ;AAiC9C,SAAS,oBAAoB,OAI3B;AACP,SAAO,OAAO,cAAc;AAC5B,QAAM,UAAU,MAAM,OAAO,OAAO,cAAc,CAAC;AAoBnD,QAAM,SAAS,MAAM;AACnB,QAAI,CAAC,cAAc,EAAG,QAAO,MAAM;AAAA,EACrC,CAAC;AAaH;AAGA,SAAS,gBAAyB;AAChC,SAAQ,WAAuC,aAAa,MAAM;AACpE;","names":[]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/roles/test/setup/install-once.ts
|
|
2
|
+
var INSTALLED = /* @__PURE__ */ Symbol.for("@hublo/sentinel/test/setup/installed");
|
|
3
|
+
function alreadyInstalled(target, name) {
|
|
4
|
+
const marked = target;
|
|
5
|
+
const installed = marked[INSTALLED] ??= /* @__PURE__ */ new Set();
|
|
6
|
+
if (installed.has(name)) return true;
|
|
7
|
+
installed.add(name);
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
alreadyInstalled
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=chunk-NUOQXAYR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles/test/setup/install-once.ts"],"sourcesContent":["/**\n * The guard every setup installer needs, because a setup file runs ONCE PER TEST FILE.\n *\n * ## The defect this exists to close\n *\n * Vitest re-executes each entry of `setupFiles` for every test file. Under the isolation Vitest\n * gives by default that is harmless: the file also gets a fresh module registry, so the `vi` a\n * setup wraps is a fresh `vi`, and last file's wrapper went away with last file's registry.\n *\n * Without isolation the registry is the worker's, so `vi` is ONE object for every file, and an\n * installer that wraps `vi.fn` wraps last file's wrapper. Measured on `front-components`, 230 test\n * files: the chain reaches 230 deep and the suite dies with\n * `RangeError: Maximum call stack size exceeded`. What makes it worth a named guard rather than a\n * fix at each call site is how it fails. 26 files never ran, and the reporter said\n * `204 passed (204)` with no red: the loss was only visible by comparing the test COUNT against\n * the run before it.\n *\n * `installJestErrorEquality` fails the same way more quietly, adding one more equality tester to\n * `expect` per file rather than overflowing anything.\n *\n * ## Why the mark goes on the target and not on `globalThis`\n *\n * The mark has to answer \"is the object I am about to wrap already wrapped\", and only the object\n * itself can answer that. On `globalThis` the answer would be wrong in the isolated case: Vitest\n * builds a new environment per file, but a `node`-environment worker keeps one global object, so a\n * flag set there would survive into a file whose `vi` is brand new and unwrapped, and the\n * installer would skip the work it still had to do.\n */\n\n/** Stable across copies of this module, which pnpm's layout makes possible. */\nconst INSTALLED = Symbol.for('@hublo/sentinel/test/setup/installed')\n\ntype Marked = Record<symbol, Set<string> | undefined>\n\n/**\n * Records `name` against `target` and says whether it was already there.\n *\n * Written as a question an installer asks on its first line, `if (alreadyInstalled(vi, '...'))\n * return`, so the guarantee travels with the installer instead of depending on every caller\n * remembering it.\n */\nexport function alreadyInstalled(target: object, name: string): boolean {\n const marked = target as Marked\n const installed = (marked[INSTALLED] ??= new Set<string>())\n\n if (installed.has(name)) return true\n\n installed.add(name)\n return false\n}\n"],"mappings":";AA8BA,IAAM,YAAY,uBAAO,IAAI,sCAAsC;AAW5D,SAAS,iBAAiB,QAAgB,MAAuB;AACtE,QAAM,SAAS;AACf,QAAM,YAAa,OAAO,SAAS,MAAM,oBAAI,IAAY;AAEzD,MAAI,UAAU,IAAI,IAAI,EAAG,QAAO;AAEhC,YAAU,IAAI,IAAI;AAClB,SAAO;AACT;","names":[]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/shared/package-json.ts
|
|
2
|
-
import { existsSync, readFileSync } from "fs";
|
|
3
|
-
import { basename, dirname, join } from "path";
|
|
4
|
-
import { fileURLToPath } from "url";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
5
|
function readOwnPackage() {
|
|
6
6
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
for (; ; ) {
|
|
@@ -51,8 +51,8 @@ function moduleName(dir) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// src/core/workspace-prep.ts
|
|
54
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
55
|
-
import { dirname as dirname2, join as join2, relative } from "path";
|
|
54
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
55
|
+
import { dirname as dirname2, join as join2, relative } from "node:path";
|
|
56
56
|
|
|
57
57
|
// src/core/settings.ts
|
|
58
58
|
var WORKSPACE_ROOT_MARKER = "nx.json";
|
|
@@ -261,4 +261,4 @@ export {
|
|
|
261
261
|
ensureWorkspacePrep,
|
|
262
262
|
inspectWorkspacePrep
|
|
263
263
|
};
|
|
264
|
-
//# sourceMappingURL=chunk-
|
|
264
|
+
//# sourceMappingURL=chunk-NW6UHNYX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/package-json.ts","../src/core/workspace-prep.ts","../src/core/settings.ts"],"sourcesContent":["/**\n * package.json helpers, shared by the CLI and the engine.\n */\nimport { existsSync, readFileSync } from 'node:fs'\nimport { basename, dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n/**\n * sentinel's OWN version, for `--version`. We must NOT use\n * `process.env.npm_package_version`, that is the version of whatever script\n * invoked us (an app's package.json, or nothing). Instead walk up from this\n * module to the nearest package.json, which is always sentinel's own, whether\n * running from source or the bundled dist.\n */\n/**\n * sentinel's OWN name + version, read by walking up from this module to the nearest\n * package.json. Used both for `--version` and to pin the exact dependency `--init`\n * writes into an adopted module, so a module always references the version that adopted it.\n */\nexport function readOwnPackage(): { name: string; version: string; bin?: string } {\n let dir = dirname(fileURLToPath(import.meta.url))\n for (;;) {\n const pkgPath = join(dir, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {\n name?: string\n version?: string\n bin?: string | Record<string, string>\n }\n if (typeof pkg.version === 'string') {\n // The bin PATH, not the command name: a package that adopts itself has to invoke\n // its own build output, because the `.bin` shim does not exist yet (see below).\n const bin = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin ?? {})[0]\n return { name: pkg.name ?? '@hublo/sentinel', version: pkg.version, bin }\n }\n } catch {\n // Malformed package.json; keep walking up.\n }\n }\n const parent = dirname(dir)\n if (parent === dir) return { name: '@hublo/sentinel', version: '0.0.0' } // filesystem root\n dir = parent\n }\n}\n\nexport function readOwnVersion(): string {\n return readOwnPackage().version\n}\n\n/**\n * Read a module's package.json for framework detection; tolerant of a missing or\n * malformed file (returns `{}`, so detection falls through to the `node` default).\n */\nexport function readProjectPackageJson(dir: string): {\n /** The package name, which is also what nx calls the project when there is no `project.json`. */\n name?: string\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n /**\n * How the module's `.js`/`.ts` files are loaded. It decides whether a Vite config can import\n * an ESM-only package at all, which is why the build role reads it before rewriting one.\n */\n type?: string\n /**\n * What nx alone needs per target, which every role writes here and none could read back.\n *\n * Typed loosely on purpose: this reader is shared by every role and each one knows the shape of\n * its own target. Declared at all because a role that WRITES a block and then has to cast to\n * read it is one refactor away from reading a key it never wrote.\n */\n nx?: { targets?: Record<string, Record<string, unknown>> }\n} {\n const path = join(dir, 'package.json')\n if (!existsSync(path)) return {}\n try {\n return JSON.parse(readFileSync(path, 'utf8')) as Record<string, never>\n } catch {\n process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.\\n`)\n return {}\n }\n}\n\n/**\n * The nx project name for a module, read from its `project.json`, or undefined if\n * there is none/unreadable. Used when `--init` has to scaffold a `package.json`\n * for a module that only has a `project.json` (common for nx apps/services): the\n * scaffolded file borrows this name so it is a valid, uniquely-named workspace\n * package. Reusable by any tool that scaffolds, not just TypeScript.\n */\nexport function readNxProjectName(dir: string): string | undefined {\n const path = join(dir, 'project.json')\n if (!existsSync(path)) return undefined\n try {\n const parsed = JSON.parse(readFileSync(path, 'utf8')) as { name?: unknown }\n return typeof parsed.name === 'string' ? parsed.name : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * What nx calls this module, resolved the way nx itself resolves it.\n *\n * The precedence is `project.json`, then `package.json`, then the directory, and only the\n * first and last of those were being consulted. A module with no `project.json` therefore\n * answered with its DIRECTORY while nx answered with its package name, and the two disagree\n * in the real repo: `apps/front/hr-management` is `front-hr-management` to nx, as is\n * `apps/front/storybook-app`.\n *\n * That gap is not cosmetic, because this name is what `--module` is matched against. The\n * `serve` script `--init` writes carries `--module <name>`, so a module in that shape was\n * adopted with a script naming a project the workspace does not contain, and `pnpm run serve`\n * failed the day it was adopted with nothing having reported it.\n *\n * One function so the two ends cannot drift again: whoever WRITES the name and whoever\n * RESOLVES it now read the same source.\n */\nexport function moduleName(dir: string): string {\n const declared = readProjectPackageJson(dir).name\n return readNxProjectName(dir) ?? (declared === undefined ? basename(dir) : declared)\n}\n","/**\n * Workspace-level preparation that `--init` applies once, at the workspace root, so an\n * adopted module actually installs and resolves cleanly. `--init` writes a module's own\n * files; this is the small, deliberate exception where it also touches the root, because\n * \"set this module up\" is not honest if the workspace it lives in cannot then install or\n * resolve it.\n *\n * Kept intentionally narrow (YAGNI): it applies the specific fixes a workspace's OWN\n * config shows it needs, and is a no-op everywhere else. It is NOT a speculative dedupe\n * engine, when a second, different case appears we generalize from real cases instead of\n * predicting them. The two fixes today:\n *\n * - i18next singleton override. If the root declares a second TypeScript via the\n * `@typescript/native` alias (a repo mid-migration running two TypeScripts), adopting a\n * module re-resolves pnpm and i18next, which carries an optional type-only `typescript`\n * peer, forks into two peer-keyed variants. That forks react-i18next into two React\n * contexts and breaks translation-singleton tests. Pinning i18next's `typescript` peer\n * to the declared TS collapses it to one. Read from the root's own declaration, so it\n * tracks the version and disappears (no-op) once the migration removes the second TS.\n * Written to `pnpm-workspace.yaml`, the only place current pnpm reads overrides from.\n *\n * - release-age allow-list. If the root uses pnpm's `minimumReleaseAgeExclude` gate, a\n * freshly published sentinel is younger than the gate and will not install; allow-list\n * it. Only touched when the gate is actually configured.\n *\n * - root formatter exclusion. A module that has adopted the FORMAT role is formatted by\n * oxfmt, from its own config. The root Prettier does not know that and still owns every\n * file in the repo, so `nx format:write` or `prettier --write .` from the root reformats\n * the module back to the ROOT's style, and the next `pnpm run format` in the module\n * reverses it. Two formatters fighting over the same files, with whichever ran last\n * winning, is the exact state adoption removes INSIDE a module, and it has to be removed\n * at the root too. So the module is excluded in the root `.prettierignore` as it adopts,\n * and the root's own config is otherwise untouched: it still serves every module that has\n * not migrated.\n *\n * All three edits are idempotent and defensive: absent the trigger (no second TypeScript, no\n * release-age gate, no root Prettier) each is skipped, so the step is safe on any workspace.\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs'\nimport { dirname, join, relative } from 'node:path'\n\nimport { readOwnPackage } from '../shared/package-json.js'\n\nimport { WORKSPACE_ROOT_MARKER } from './settings.js'\n\nconst OVERRIDE_KEY = 'i18next>typescript'\nconst NATIVE_TS_ALIAS = '@typescript/native' // `npm:typescript@^7.x`: a workspace's second, real TS\nconst WORKSPACE_YAML = 'pnpm-workspace.yaml'\nconst RELEASE_AGE_KEY = 'minimumReleaseAgeExclude'\nconst LIST_ITEM = /^(\\s*)-\\s+(.*?)\\s*$/\nconst MAP_ENTRY = /^(\\s+)(.*?):\\s+(.*?)\\s*$/\n\ninterface RootPackageJson {\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n pnpm?: { overrides?: Record<string, string> }\n}\n\n/** Walk up from a module directory to the workspace root (the dir holding the marker). */\nexport function findWorkspaceRoot(startDir: string): string | undefined {\n let dir = startDir\n for (;;) {\n if (existsSync(join(dir, WORKSPACE_ROOT_MARKER))) return dir\n const parent = dirname(dir)\n if (parent === dir) return undefined\n dir = parent\n }\n}\n\n/**\n * The TypeScript version to pin i18next's peer to, read from the root's own\n * `@typescript/native` alias (`npm:typescript@^7.0.2` -> `7.0.2`). Returns undefined when\n * the alias is absent (no second TypeScript, so no fork to fix).\n */\nfunction declaredNativeTs(pkg: RootPackageJson): string | undefined {\n const spec = pkg.dependencies?.[NATIVE_TS_ALIAS] ?? pkg.devDependencies?.[NATIVE_TS_ALIAS]\n if (!spec) return undefined\n const version = spec.slice(spec.lastIndexOf('@') + 1).replace(/^[\\^~>=<\\s]+/, '')\n return version || undefined\n}\n\n/**\n * Ensure the i18next singleton override matches the declared second TypeScript. No-op when\n * the workspace runs a single TypeScript (no `@typescript/native`). Returns a message when\n * it changed (or would change, under dryRun), else undefined.\n *\n * The override is written to `pnpm-workspace.yaml`, never to `package.json#pnpm`: pnpm 11\n * stopped reading the `pnpm` field and does so SILENTLY, with no warning and no deprecation\n * (pnpm/pnpm#11536), so an override left in `package.json` is simply not applied. The yaml is\n * read by pnpm 10 and 11 alike (https://pnpm.io/settings), which is why there is one target\n * here rather than a version-dependent fallback.\n */\nfunction ensureI18nextSingleton(root: string, dryRun: boolean): string | undefined {\n const pkgPath = join(root, 'package.json')\n if (!existsSync(pkgPath)) return undefined\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as RootPackageJson\n const want = declaredNativeTs(pkg)\n if (!want) return undefined // single TypeScript: nothing forks\n\n const yamlPath = join(root, WORKSPACE_YAML)\n if (!existsSync(yamlPath)) return undefined // no workspace file: nowhere pnpm would read it\n const lines = readFileSync(yamlPath, 'utf8').split('\\n')\n const entry = findOverrideEntry(lines)\n if (entry.valueIdx !== undefined && entry.value === want) return undefined // already pinned\n if (dryRun) return `would pin ${OVERRIDE_KEY} to ${want} in ${WORKSPACE_YAML} (i18next singleton)`\n\n applyOverride(lines, entry, want)\n writeFileSync(yamlPath, lines.join('\\n'))\n return `pinned ${OVERRIDE_KEY} to ${want} in ${WORKSPACE_YAML} (i18next singleton)`\n}\n\n/** Where the override sits in the yaml, if at all, plus where a new one would be inserted. */\ninterface OverrideEntry {\n /** Index of the `OVERRIDE_KEY` line, when the key is already declared. */\n valueIdx?: number\n /** Its current value, unquoted. */\n value?: string\n /** Index of the `overrides:` key line, when the block exists. */\n blockIdx?: number\n /** Index of the block's last entry, so an insertion keeps the block contiguous. */\n lastEntryIdx?: number\n /** Indentation the block's entries use. */\n indent: string\n}\n\n/**\n * Locate the `overrides:` block and the i18next key inside it. A targeted text read rather\n * than a yaml parse, for the same reason `ensureReleaseAgeAllowList` does it: this file is\n * hand-maintained and round-tripping it through a parser would drop its comments and ordering.\n */\nfunction findOverrideEntry(lines: string[]): OverrideEntry {\n const blockIdx = lines.findIndex((line) => line.replace(/\\s+$/, '') === 'overrides:')\n if (blockIdx === -1) return { indent: ' ' }\n\n let lastEntryIdx = blockIdx\n let indent = ' '\n for (let i = blockIdx + 1; i < lines.length; i++) {\n const match = lines[i]?.match(MAP_ENTRY)\n if (!match) break // dedent or blank line: the block ended\n indent = match[1] ?? indent\n lastEntryIdx = i\n if ((match[2] ?? '').replace(/^['\"]|['\"]$/g, '') === OVERRIDE_KEY) {\n return {\n valueIdx: i,\n value: (match[3] ?? '').replace(/^['\"]|['\"]$/g, ''),\n blockIdx,\n lastEntryIdx,\n indent,\n }\n }\n }\n return { blockIdx, lastEntryIdx, indent }\n}\n\n/** Update the key in place, append it to an existing block, or open a block at the end. */\nfunction applyOverride(lines: string[], entry: OverrideEntry, want: string): void {\n const line = `${entry.indent}'${OVERRIDE_KEY}': ${want}`\n if (entry.valueIdx !== undefined) {\n lines[entry.valueIdx] = line\n return\n }\n if (entry.lastEntryIdx !== undefined) {\n lines.splice(entry.lastEntryIdx + 1, 0, line)\n return\n }\n // No `overrides:` block at all: open one, keeping a single trailing newline.\n while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()\n lines.push('overrides:', line, '')\n}\n\n/**\n * Ensure sentinel is allow-listed under `minimumReleaseAgeExclude` so a fresh publish\n * installs. No-op when the workspace has no such gate. Targeted text edit so comments and\n * formatting are preserved. Returns a message when it changed (or would), else undefined.\n */\nfunction ensureReleaseAgeAllowList(root: string, dryRun: boolean): string | undefined {\n const yamlPath = join(root, WORKSPACE_YAML)\n if (!existsSync(yamlPath)) return undefined\n const own = readOwnPackage().name\n const lines = readFileSync(yamlPath, 'utf8').split('\\n')\n const keyIdx = lines.findIndex((line) => line.replace(/\\s+$/, '') === `${RELEASE_AGE_KEY}:`)\n if (keyIdx === -1) return undefined // no release-age gate in use\n\n let lastItemIdx = keyIdx\n let indent = ' '\n for (let i = keyIdx + 1; i < lines.length; i++) {\n const match = lines[i]?.match(LIST_ITEM)\n if (!match) break\n indent = match[1] ?? indent\n lastItemIdx = i\n if ((match[2] ?? '').replace(/^['\"]|['\"]$/g, '') === own) return undefined // already listed\n }\n if (dryRun) return `would allow-list ${own} under ${RELEASE_AGE_KEY}`\n\n lines.splice(lastItemIdx + 1, 0, `${indent}- '${own}'`)\n writeFileSync(yamlPath, lines.join('\\n'))\n return `allow-listed ${own} under ${RELEASE_AGE_KEY} in ${WORKSPACE_YAML}`\n}\n\n/** The root ignore file Prettier reads, and the section sentinel owns inside it. */\nconst PRETTIER_IGNORE = '.prettierignore'\nconst EXCLUSION_HEADER = '# @hublo/sentinel: modules formatted by oxfmt, not by the root Prettier'\n\n/** Root Prettier configs. Their presence is what makes an exclusion meaningful. */\nconst ROOT_PRETTIER_CONFIGS = [\n '.prettierrc',\n '.prettierrc.json',\n '.prettierrc.json5',\n '.prettierrc.yml',\n '.prettierrc.yaml',\n '.prettierrc.js',\n '.prettierrc.cjs',\n '.prettierrc.mjs',\n 'prettier.config.js',\n 'prettier.config.cjs',\n 'prettier.config.mjs',\n]\n\n/**\n * Exclude a module that now formats itself from the ROOT Prettier.\n *\n * Written as a directory pattern anchored at the root (`/apps/front/x/`), which is what\n * gitignore syntax means by \"this exact directory\", so a module cannot accidentally exclude a\n * same-named directory somewhere else.\n *\n * Skipped entirely when the root has no Prettier at all: there is then nothing to conflict\n * with, and creating an ignore file for a tool the workspace does not use would be noise.\n */\nfunction ensureFormatterExclusion(\n root: string,\n moduleDir: string,\n dryRun: boolean,\n): string | undefined {\n const rel = relative(root, moduleDir).replaceAll('\\\\', '/')\n if (rel === '' || rel.startsWith('..')) return undefined // the root itself, or outside it\n\n const ignorePath = join(root, PRETTIER_IGNORE)\n const hasPrettier =\n existsSync(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync(join(root, name)))\n if (!hasPrettier) return undefined\n\n const pattern = `/${rel}/`\n const existing = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : ''\n const lines = existing.split('\\n')\n // Already excluded, in whichever spelling a human may have used.\n if (lines.some((line) => line.trim().replace(/\\/$/, '') === pattern.replace(/\\/$/, ''))) {\n return undefined\n }\n if (dryRun) return `would exclude ${rel} from the root Prettier (${PRETTIER_IGNORE})`\n\n const headerIdx = lines.findIndex((line) => line.trim() === EXCLUSION_HEADER)\n if (headerIdx === -1) {\n const prefix = existing.trim() === '' ? '' : `${existing.replace(/\\n*$/, '')}\\n\\n`\n writeFileSync(ignorePath, `${prefix}${EXCLUSION_HEADER}\\n${pattern}\\n`)\n } else {\n // Keep the managed section together and sorted, so the list stays readable as it grows\n // to 150 entries and a diff shows one added line rather than a reshuffle.\n let end = headerIdx + 1\n while (end < lines.length && lines[end]?.trim().startsWith('/')) end += 1\n const managed = [...lines.slice(headerIdx + 1, end), pattern].sort()\n writeFileSync(\n ignorePath,\n [...lines.slice(0, headerIdx + 1), ...managed, ...lines.slice(end)].join('\\n'),\n )\n }\n return `excluded ${rel} from the root Prettier (${PRETTIER_IGNORE}): it formats itself now`\n}\n\n/**\n * Apply every workspace-level fix the given root needs. Returns the human-readable\n * messages for what changed (empty when nothing did). Under dryRun nothing is written and\n * the messages describe what would change.\n *\n * `formattedModule` is the module that just adopted the FORMAT role, when one did. Absent\n * for a lint-only or TypeScript-only `--init`, because a module that has not adopted the\n * formatter must keep being formatted by the root.\n */\nexport function ensureWorkspacePrep(opts: {\n root: string\n dryRun?: boolean\n formattedModule?: string\n}): string[] {\n const dryRun = Boolean(opts.dryRun)\n return [\n ensureI18nextSingleton(opts.root, dryRun),\n ensureReleaseAgeAllowList(opts.root, dryRun),\n opts.formattedModule === undefined\n ? undefined\n : ensureFormatterExclusion(opts.root, opts.formattedModule, dryRun),\n ].filter((message): message is string => message !== undefined)\n}\n\n/**\n * One standing change sentinel has made to the workspace ROOT, and whether it is still earned.\n *\n * This exists because the three root writes are the only thing sentinel does that nobody can\n * see afterwards. `--init` announces each one on the run that makes it, and then it is a line\n * in someone else's file forever. Two consequences already bit us.\n *\n * The `i18next>typescript` override re-resolved `apps/front/console` from a PR that touched no\n * file under it, so CODEOWNERS pulled nobody and its owners learned about it afterwards.\n *\n * And every one of them is TEMPORARY with its removal condition written in prose. That failed\n * three times in one week: `SENTINEL_TAG=alpha` in the migration-page workflow kept building\n * with a pre-release long after 1.1.0 went stable and reported `Format 0/439` for 15 adopted\n * modules; ADR 0042 and its RFC both sat mid-flight past their deadline; and this release-age\n * exclusion is still waiting on \"once sentinel's release cadence settles\". Nothing failed when\n * any of them expired, because a comment cannot fail.\n *\n * So each entry carries the condition that JUSTIFIES it, evaluated now rather than described.\n * A trigger that has gone away makes the entry dead config, and dead config is something a\n * command can say out loud instead of something a reader has to notice.\n *\n * Read-only, and offline: it inspects the same files `--init` writes and nothing else. What it\n * deliberately does NOT claim is anything needing the npm registry — whether a published\n * version is old enough to clear the release-age gate is not knowable from the repo, so it is\n * stated as the standing exemption it is rather than guessed at.\n */\nexport interface WorkspacePrepEntry {\n /** `rule` to match the shape the shared renderer already prints as a labelled block. */\n rule: string\n /** What it does, whether its trigger still holds, and what would retire it. */\n reason: string\n}\n\n/** The root-level changes sentinel maintains here, each with its justification checked now. */\nexport function inspectWorkspacePrep(root: string): WorkspacePrepEntry[] {\n const entries: WorkspacePrepEntry[] = []\n // Tolerant, unlike the write path above, and the difference is deliberate. `--init` should\n // fail loudly on a root it cannot parse, because it is about to edit it. `--inspect` is a\n // read verb that sweeps 441 modules, and one malformed root file must not take the whole\n // sweep down: the modules still have honest answers to give. Caught by its own test.\n let pkg: RootPackageJson = {}\n const pkgPath = join(root, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as RootPackageJson\n } catch {\n pkg = {}\n }\n }\n\n const yamlPath = join(root, WORKSPACE_YAML)\n const yaml = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : undefined\n\n const pinned = yaml === undefined ? undefined : findOverrideEntry(yaml.split('\\n')).value\n if (pinned !== undefined) {\n const stillForks = declaredNativeTs(pkg)\n entries.push({\n rule: `${WORKSPACE_YAML} → overrides.${OVERRIDE_KEY}`,\n reason:\n stillForks === undefined\n ? `pinned to ${pinned}, but the root no longer declares ${NATIVE_TS_ALIAS}, so nothing ` +\n `forks any more. This override is now dead config and can be removed.`\n : `pinned to ${pinned}, matching the second TypeScript the root declares ` +\n `(${NATIVE_TS_ALIAS}). It re-resolves i18next for EVERY module that depends on it, ` +\n `not only adopted ones. It retires with the second TypeScript.`,\n })\n }\n\n // A copy left in `package.json#pnpm` by an older sentinel, or by hand. Reported because it\n // is INERT rather than redundant: pnpm 11 dropped the `pnpm` field with no warning\n // (pnpm/pnpm#11536), so it reads as protection that is not applied.\n const legacy = pkg.pnpm?.overrides?.[OVERRIDE_KEY]\n if (legacy !== undefined) {\n entries.push({\n rule: `package.json → pnpm.overrides.${OVERRIDE_KEY}`,\n reason:\n `pinned to ${legacy}, but pnpm 11 no longer reads the \\`pnpm\\` field of package.json ` +\n `and says nothing about it, so this override is not applied. Move it to ` +\n `${WORKSPACE_YAML} under \\`overrides:\\`, which pnpm 10 and 11 both read.`,\n })\n }\n\n const own = readOwnPackage().name\n if (yaml !== undefined) {\n const listed = new RegExp(`^\\\\s*-\\\\s*['\"]?${own.replace('/', '\\\\/')}['\"]?\\\\s*$`, 'm').test(yaml)\n if (listed) {\n const gated = new RegExp(`^\\\\s*minimumReleaseAge\\\\s*:`, 'm').test(yaml)\n entries.push({\n rule: `${WORKSPACE_YAML} → ${RELEASE_AGE_KEY}: ${own}`,\n reason: gated\n ? `${own} is exempt from this workspace's release-age gate, so a freshly published ` +\n `version installs instead of being held. That is a standing exemption from a ` +\n `supply-chain control: the exposure is one bump wide, since the version is pinned ` +\n `and the lockfile carries its integrity, so a re-publish of the same version does ` +\n `not pass. Retire it once a bump can simply wait out the window.`\n : `${own} is allow-listed, but this workspace no longer sets minimumReleaseAge, so ` +\n `there is no gate to be exempt from. This entry is now dead config and can be removed.`,\n })\n }\n }\n\n return entries\n}\n","/**\n * Tunable settings: the knobs you would actually change. The fixed vocabulary and\n * its types live in `domain.ts`.\n *\n * Note: the preset is DETECTED from a module's dependencies (see\n * `detect-framework.ts`), deterministically, `node` when no framework dep is present.\n * `--preset` overrides the detection (recommended for `--init`, since hoisted deps\n * can make detection return `node`). The detection table lives in `detect-framework.ts`.\n */\n\n/** A file that marks a workspace root; sentinel refuses to operate there. */\nexport const WORKSPACE_ROOT_MARKER = 'nx.json'\n\n/**\n * How many structured diagnostics a report embeds per module, when the caller does not say.\n *\n * A cap, not a limit on what is counted: the totals stay exact and `diagnosticsTruncated`\n * says when rows were dropped, so a bounded envelope never reads as a clean module. Shared\n * by every role, because a consumer reading `--json` across roles should not have to learn\n * a different cap for each one. `--max-diagnostics 0` means no cap.\n */\nexport const DEFAULT_MAX_DIAGNOSTICS = 100\n"],"mappings":";AAGA,SAAS,YAAY,oBAAoB;AACzC,SAAS,UAAU,SAAS,YAAY;AACxC,SAAS,qBAAqB;AAcvB,SAAS,iBAAkE;AAChF,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,aAAS;AACP,UAAM,UAAU,KAAK,KAAK,cAAc;AACxC,QAAI,WAAW,OAAO,GAAG;AACvB,UAAI;AACF,cAAM,MAAM,KAAK,MAAM,aAAa,SAAS,MAAM,CAAC;AAKpD,YAAI,OAAO,IAAI,YAAY,UAAU;AAGnC,gBAAM,MAAM,OAAO,IAAI,QAAQ,WAAW,IAAI,MAAM,OAAO,OAAO,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;AAClF,iBAAO,EAAE,MAAM,IAAI,QAAQ,mBAAmB,SAAS,IAAI,SAAS,IAAI;AAAA,QAC1E;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO,EAAE,MAAM,mBAAmB,SAAS,QAAQ;AACvE,UAAM;AAAA,EACR;AACF;AAEO,SAAS,iBAAyB;AACvC,SAAO,eAAe,EAAE;AAC1B;AAMO,SAAS,uBAAuB,KAkBrC;AACA,QAAM,OAAO,KAAK,KAAK,cAAc;AACrC,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO,CAAC;AAC/B,MAAI;AACF,WAAO,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;AAAA,EAC9C,QAAQ;AACN,YAAQ,OAAO,MAAM,6BAA6B,IAAI;AAAA,CAA6B;AACnF,WAAO,CAAC;AAAA,EACV;AACF;AASO,SAAS,kBAAkB,KAAiC;AACjE,QAAM,OAAO,KAAK,KAAK,cAAc;AACrC,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO;AAC9B,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;AACpD,WAAO,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAmBO,SAAS,WAAW,KAAqB;AAC9C,QAAM,WAAW,uBAAuB,GAAG,EAAE;AAC7C,SAAO,kBAAkB,GAAG,MAAM,aAAa,SAAY,SAAS,GAAG,IAAI;AAC7E;;;ACnFA,SAAS,cAAAA,aAAY,gBAAAC,eAAc,qBAAqB;AACxD,SAAS,WAAAC,UAAS,QAAAC,OAAM,gBAAgB;;;AC5BjC,IAAM,wBAAwB;AAU9B,IAAM,0BAA0B;;;ADwBvC,IAAM,eAAe;AACrB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB;AACvB,IAAM,kBAAkB;AACxB,IAAM,YAAY;AAClB,IAAM,YAAY;AASX,SAAS,kBAAkB,UAAsC;AACtE,MAAI,MAAM;AACV,aAAS;AACP,QAAIC,YAAWC,MAAK,KAAK,qBAAqB,CAAC,EAAG,QAAO;AACzD,UAAM,SAASC,SAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO;AAC3B,UAAM;AAAA,EACR;AACF;AAOA,SAAS,iBAAiB,KAA0C;AAClE,QAAM,OAAO,IAAI,eAAe,eAAe,KAAK,IAAI,kBAAkB,eAAe;AACzF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,KAAK,MAAM,KAAK,YAAY,GAAG,IAAI,CAAC,EAAE,QAAQ,gBAAgB,EAAE;AAChF,SAAO,WAAW;AACpB;AAaA,SAAS,uBAAuB,MAAc,QAAqC;AACjF,QAAM,UAAUD,MAAK,MAAM,cAAc;AACzC,MAAI,CAACD,YAAW,OAAO,EAAG,QAAO;AACjC,QAAM,MAAM,KAAK,MAAMG,cAAa,SAAS,MAAM,CAAC;AACpD,QAAM,OAAO,iBAAiB,GAAG;AACjC,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,MAAI,CAACD,YAAW,QAAQ,EAAG,QAAO;AAClC,QAAM,QAAQG,cAAa,UAAU,MAAM,EAAE,MAAM,IAAI;AACvD,QAAM,QAAQ,kBAAkB,KAAK;AACrC,MAAI,MAAM,aAAa,UAAa,MAAM,UAAU,KAAM,QAAO;AACjE,MAAI,OAAQ,QAAO,aAAa,YAAY,OAAO,IAAI,OAAO,cAAc;AAE5E,gBAAc,OAAO,OAAO,IAAI;AAChC,gBAAc,UAAU,MAAM,KAAK,IAAI,CAAC;AACxC,SAAO,UAAU,YAAY,OAAO,IAAI,OAAO,cAAc;AAC/D;AAqBA,SAAS,kBAAkB,OAAgC;AACzD,QAAM,WAAW,MAAM,UAAU,CAAC,SAAS,KAAK,QAAQ,QAAQ,EAAE,MAAM,YAAY;AACpF,MAAI,aAAa,GAAI,QAAO,EAAE,QAAQ,KAAK;AAE3C,MAAI,eAAe;AACnB,MAAI,SAAS;AACb,WAAS,IAAI,WAAW,GAAG,IAAI,MAAM,QAAQ,KAAK;AAChD,UAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,SAAS;AACvC,QAAI,CAAC,MAAO;AACZ,aAAS,MAAM,CAAC,KAAK;AACrB,mBAAe;AACf,SAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE,MAAM,cAAc;AACjE,aAAO;AAAA,QACL,UAAU;AAAA,QACV,QAAQ,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE;AAAA,QAClD;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,UAAU,cAAc,OAAO;AAC1C;AAGA,SAAS,cAAc,OAAiB,OAAsB,MAAoB;AAChF,QAAM,OAAO,GAAG,MAAM,MAAM,IAAI,YAAY,MAAM,IAAI;AACtD,MAAI,MAAM,aAAa,QAAW;AAChC,UAAM,MAAM,QAAQ,IAAI;AACxB;AAAA,EACF;AACA,MAAI,MAAM,iBAAiB,QAAW;AACpC,UAAM,OAAO,MAAM,eAAe,GAAG,GAAG,IAAI;AAC5C;AAAA,EACF;AAEA,SAAO,MAAM,SAAS,KAAK,MAAM,MAAM,SAAS,CAAC,MAAM,GAAI,OAAM,IAAI;AACrE,QAAM,KAAK,cAAc,MAAM,EAAE;AACnC;AAOA,SAAS,0BAA0B,MAAc,QAAqC;AACpF,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,MAAI,CAACD,YAAW,QAAQ,EAAG,QAAO;AAClC,QAAM,MAAM,eAAe,EAAE;AAC7B,QAAM,QAAQG,cAAa,UAAU,MAAM,EAAE,MAAM,IAAI;AACvD,QAAM,SAAS,MAAM,UAAU,CAAC,SAAS,KAAK,QAAQ,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG;AAC3F,MAAI,WAAW,GAAI,QAAO;AAE1B,MAAI,cAAc;AAClB,MAAI,SAAS;AACb,WAAS,IAAI,SAAS,GAAG,IAAI,MAAM,QAAQ,KAAK;AAC9C,UAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,SAAS;AACvC,QAAI,CAAC,MAAO;AACZ,aAAS,MAAM,CAAC,KAAK;AACrB,kBAAc;AACd,SAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE,MAAM,IAAK,QAAO;AAAA,EACnE;AACA,MAAI,OAAQ,QAAO,oBAAoB,GAAG,UAAU,eAAe;AAEnE,QAAM,OAAO,cAAc,GAAG,GAAG,GAAG,MAAM,MAAM,GAAG,GAAG;AACtD,gBAAc,UAAU,MAAM,KAAK,IAAI,CAAC;AACxC,SAAO,gBAAgB,GAAG,UAAU,eAAe,OAAO,cAAc;AAC1E;AAGA,IAAM,kBAAkB;AACxB,IAAM,mBAAmB;AAGzB,IAAM,wBAAwB;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAYA,SAAS,yBACP,MACA,WACA,QACoB;AACpB,QAAM,MAAM,SAAS,MAAM,SAAS,EAAE,WAAW,MAAM,GAAG;AAC1D,MAAI,QAAQ,MAAM,IAAI,WAAW,IAAI,EAAG,QAAO;AAE/C,QAAM,aAAaF,MAAK,MAAM,eAAe;AAC7C,QAAM,cACJD,YAAW,UAAU,KAAK,sBAAsB,KAAK,CAAC,SAASA,YAAWC,MAAK,MAAM,IAAI,CAAC,CAAC;AAC7F,MAAI,CAAC,YAAa,QAAO;AAEzB,QAAM,UAAU,IAAI,GAAG;AACvB,QAAM,WAAWD,YAAW,UAAU,IAAIG,cAAa,YAAY,MAAM,IAAI;AAC7E,QAAM,QAAQ,SAAS,MAAM,IAAI;AAEjC,MAAI,MAAM,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,MAAM,QAAQ,QAAQ,OAAO,EAAE,CAAC,GAAG;AACvF,WAAO;AAAA,EACT;AACA,MAAI,OAAQ,QAAO,iBAAiB,GAAG,4BAA4B,eAAe;AAElF,QAAM,YAAY,MAAM,UAAU,CAAC,SAAS,KAAK,KAAK,MAAM,gBAAgB;AAC5E,MAAI,cAAc,IAAI;AACpB,UAAM,SAAS,SAAS,KAAK,MAAM,KAAK,KAAK,GAAG,SAAS,QAAQ,QAAQ,EAAE,CAAC;AAAA;AAAA;AAC5E,kBAAc,YAAY,GAAG,MAAM,GAAG,gBAAgB;AAAA,EAAK,OAAO;AAAA,CAAI;AAAA,EACxE,OAAO;AAGL,QAAI,MAAM,YAAY;AACtB,WAAO,MAAM,MAAM,UAAU,MAAM,GAAG,GAAG,KAAK,EAAE,WAAW,GAAG,EAAG,QAAO;AACxE,UAAM,UAAU,CAAC,GAAG,MAAM,MAAM,YAAY,GAAG,GAAG,GAAG,OAAO,EAAE,KAAK;AACnE;AAAA,MACE;AAAA,MACA,CAAC,GAAG,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,GAAG,SAAS,GAAG,MAAM,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI;AAAA,IAC/E;AAAA,EACF;AACA,SAAO,YAAY,GAAG,4BAA4B,eAAe;AACnE;AAWO,SAAS,oBAAoB,MAIvB;AACX,QAAM,SAAS,QAAQ,KAAK,MAAM;AAClC,SAAO;AAAA,IACL,uBAAuB,KAAK,MAAM,MAAM;AAAA,IACxC,0BAA0B,KAAK,MAAM,MAAM;AAAA,IAC3C,KAAK,oBAAoB,SACrB,SACA,yBAAyB,KAAK,MAAM,KAAK,iBAAiB,MAAM;AAAA,EACtE,EAAE,OAAO,CAAC,YAA+B,YAAY,MAAS;AAChE;AAoCO,SAAS,qBAAqB,MAAoC;AACvE,QAAM,UAAgC,CAAC;AAKvC,MAAI,MAAuB,CAAC;AAC5B,QAAM,UAAUF,MAAK,MAAM,cAAc;AACzC,MAAID,YAAW,OAAO,GAAG;AACvB,QAAI;AACF,YAAM,KAAK,MAAMG,cAAa,SAAS,MAAM,CAAC;AAAA,IAChD,QAAQ;AACN,YAAM,CAAC;AAAA,IACT;AAAA,EACF;AAEA,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,QAAM,OAAOD,YAAW,QAAQ,IAAIG,cAAa,UAAU,MAAM,IAAI;AAErE,QAAM,SAAS,SAAS,SAAY,SAAY,kBAAkB,KAAK,MAAM,IAAI,CAAC,EAAE;AACpF,MAAI,WAAW,QAAW;AACxB,UAAM,aAAa,iBAAiB,GAAG;AACvC,YAAQ,KAAK;AAAA,MACX,MAAM,GAAG,cAAc,qBAAgB,YAAY;AAAA,MACnD,QACE,eAAe,SACX,aAAa,MAAM,qCAAqC,eAAe,sFAEvE,aAAa,MAAM,uDACf,eAAe;AAAA,IAE3B,CAAC;AAAA,EACH;AAKA,QAAM,SAAS,IAAI,MAAM,YAAY,YAAY;AACjD,MAAI,WAAW,QAAW;AACxB,YAAQ,KAAK;AAAA,MACX,MAAM,sCAAiC,YAAY;AAAA,MACnD,QACE,aAAa,MAAM,2IAEhB,cAAc;AAAA,IACrB,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,eAAe,EAAE;AAC7B,MAAI,SAAS,QAAW;AACtB,UAAM,SAAS,IAAI,OAAO,kBAAkB,IAAI,QAAQ,KAAK,KAAK,CAAC,cAAc,GAAG,EAAE,KAAK,IAAI;AAC/F,QAAI,QAAQ;AACV,YAAM,QAAQ,IAAI,OAAO,+BAA+B,GAAG,EAAE,KAAK,IAAI;AACtE,cAAQ,KAAK;AAAA,QACX,MAAM,GAAG,cAAc,WAAM,eAAe,KAAK,GAAG;AAAA,QACpD,QAAQ,QACJ,GAAG,GAAG,4XAKN,GAAG,GAAG;AAAA,MAEZ,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO;AACT;","names":["existsSync","readFileSync","dirname","join","existsSync","join","dirname","readFileSync"]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/shared/glob.ts
|
|
2
|
+
function globToRegExp(glob) {
|
|
3
|
+
let pattern = "";
|
|
4
|
+
for (let index = 0; index < glob.length; index++) {
|
|
5
|
+
const char = glob[index] ?? "";
|
|
6
|
+
if (char === "*") {
|
|
7
|
+
if (glob[index + 1] === "*") {
|
|
8
|
+
pattern += glob[index + 2] === "/" ? "(?:.*/)?" : ".*";
|
|
9
|
+
index += glob[index + 2] === "/" ? 2 : 1;
|
|
10
|
+
} else {
|
|
11
|
+
pattern += "[^/]*";
|
|
12
|
+
}
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (char === "{") {
|
|
16
|
+
const close = glob.indexOf("}", index);
|
|
17
|
+
if (close === -1) throw new Error(`sentinel: unbalanced \`{\` in glob ${glob}`);
|
|
18
|
+
const alternatives = glob.slice(index + 1, close).split(",");
|
|
19
|
+
pattern += `(?:${alternatives.map((one) => one.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`;
|
|
20
|
+
index = close;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
pattern += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
return new RegExp(`^${pattern}$`);
|
|
26
|
+
}
|
|
27
|
+
function matchesTsconfigPattern(pattern, relative) {
|
|
28
|
+
const cleaned = pattern.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
29
|
+
if (globToRegExp(cleaned).test(relative)) return true;
|
|
30
|
+
const asDirectory = cleaned.endsWith("/**") ? cleaned.slice(0, -3) : cleaned;
|
|
31
|
+
return globToRegExp(`${asDirectory}/**/*`).test(relative);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
globToRegExp,
|
|
36
|
+
matchesTsconfigPattern
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=chunk-O7REVMOC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/glob.ts"],"sourcesContent":["/**\n * The glob dialect a config file uses for a FILE NAME, reduced to what that needs.\n *\n * Shared because two roles read globs out of configuration and neither needs a glob library: the\n * build role reads `@nx/webpack`'s asset declarations, and the test role reads a tsconfig's\n * `include` and `exclude`. A second copy of this would drift from the first, which is a mistake\n * this package has already made once with the binary resolver.\n *\n * Three forms, and anything else is taken literally: `**` for any depth, `*` within a segment, and\n * a `{a,b}` alternation. A pattern that uses something else matches nothing rather than matching\n * by accident, which is the behaviour a caller can check.\n */\nexport function globToRegExp(glob: string): RegExp {\n let pattern = ''\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index] ?? ''\n if (char === '*') {\n if (glob[index + 1] === '*') {\n // `**/` matches any number of directories, including none.\n pattern += glob[index + 2] === '/' ? '(?:.*/)?' : '.*'\n index += glob[index + 2] === '/' ? 2 : 1\n } else {\n pattern += '[^/]*'\n }\n continue\n }\n if (char === '{') {\n const close = glob.indexOf('}', index)\n if (close === -1) throw new Error(`sentinel: unbalanced \\`{\\` in glob ${glob}`)\n const alternatives = glob.slice(index + 1, close).split(',')\n pattern += `(?:${alternatives.map((one) => one.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|')})`\n index = close\n continue\n }\n pattern += char.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&')\n }\n return new RegExp(`^${pattern}$`)\n}\n\n/**\n * A tsconfig's `include` / `exclude`, applied to a path relative to that config.\n *\n * ⚠️ A tsconfig pattern ending in a DIRECTORY matches everything under it, and one naming no\n * extension matches the TypeScript ones. `src/tests/functional/**` and `src/tests` both have to\n * claim `src/tests/functional/a.spec.ts`, which a plain file-name match does not do.\n */\nexport function matchesTsconfigPattern(pattern: string, relative: string): boolean {\n const cleaned = pattern.replace(/^\\.\\//, '').replace(/\\/+$/, '')\n if (globToRegExp(cleaned).test(relative)) return true\n // A directory, named with or without a trailing `/**`, claims everything beneath it.\n const asDirectory = cleaned.endsWith('/**') ? cleaned.slice(0, -3) : cleaned\n return globToRegExp(`${asDirectory}/**/*`).test(relative)\n}\n"],"mappings":";AAYO,SAAS,aAAa,MAAsB;AACjD,MAAI,UAAU;AACd,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;AAChD,UAAM,OAAO,KAAK,KAAK,KAAK;AAC5B,QAAI,SAAS,KAAK;AAChB,UAAI,KAAK,QAAQ,CAAC,MAAM,KAAK;AAE3B,mBAAW,KAAK,QAAQ,CAAC,MAAM,MAAM,aAAa;AAClD,iBAAS,KAAK,QAAQ,CAAC,MAAM,MAAM,IAAI;AAAA,MACzC,OAAO;AACL,mBAAW;AAAA,MACb;AACA;AAAA,IACF;AACA,QAAI,SAAS,KAAK;AAChB,YAAM,QAAQ,KAAK,QAAQ,KAAK,KAAK;AACrC,UAAI,UAAU,GAAI,OAAM,IAAI,MAAM,sCAAsC,IAAI,EAAE;AAC9E,YAAM,eAAe,KAAK,MAAM,QAAQ,GAAG,KAAK,EAAE,MAAM,GAAG;AAC3D,iBAAW,MAAM,aAAa,IAAI,CAAC,QAAQ,IAAI,QAAQ,uBAAuB,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC;AAChG,cAAQ;AACR;AAAA,IACF;AACA,eAAW,KAAK,QAAQ,sBAAsB,MAAM;AAAA,EACtD;AACA,SAAO,IAAI,OAAO,IAAI,OAAO,GAAG;AAClC;AASO,SAAS,uBAAuB,SAAiB,UAA2B;AACjF,QAAM,UAAU,QAAQ,QAAQ,SAAS,EAAE,EAAE,QAAQ,QAAQ,EAAE;AAC/D,MAAI,aAAa,OAAO,EAAE,KAAK,QAAQ,EAAG,QAAO;AAEjD,QAAM,cAAc,QAAQ,SAAS,KAAK,IAAI,QAAQ,MAAM,GAAG,EAAE,IAAI;AACrE,SAAO,aAAa,GAAG,WAAW,OAAO,EAAE,KAAK,QAAQ;AAC1D;","names":[]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/shared/jsonc.ts
|
|
2
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
3
|
+
function parseJsonc(text, source = "config") {
|
|
4
|
+
const errors = [];
|
|
5
|
+
const value = parse(text, errors, { allowTrailingComma: true });
|
|
6
|
+
if (errors.length > 0) {
|
|
7
|
+
const details = errors.map((error) => printParseErrorCode(error.error)).join(", ");
|
|
8
|
+
throw new Error(`${source}: malformed JSONC (${details}).`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
parseJsonc
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=chunk-QXFCZON7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/jsonc.ts"],"sourcesContent":["/**\n * JSONC (JSON with comments + trailing commas) helpers. tsconfig files are JSONC,\n * so reading them with plain `JSON.parse` throws on real projects (e.g.\n * host-admin's tsconfig.app.json has comments). Backed by jsonc-parser (the VS\n * Code library), which also underpins content-preserving edits (added with the\n * `--init` merge).\n */\nimport { parse, printParseErrorCode, type ParseError } from 'jsonc-parser'\n\n/**\n * Parse JSONC text into a value. Throws with a clear message listing the parse\n * errors, so a malformed config fails loudly rather than silently mis-reading.\n */\nexport function parseJsonc<T = unknown>(text: string, source = 'config'): T {\n const errors: ParseError[] = []\n const value = parse(text, errors, { allowTrailingComma: true }) as T\n if (errors.length > 0) {\n const details = errors.map((error) => printParseErrorCode(error.error)).join(', ')\n throw new Error(`${source}: malformed JSONC (${details}).`)\n }\n return value\n}\n"],"mappings":";AAOA,SAAS,OAAO,2BAA4C;AAMrD,SAAS,WAAwB,MAAc,SAAS,UAAa;AAC1E,QAAM,SAAuB,CAAC;AAC9B,QAAM,QAAQ,MAAM,MAAM,QAAQ,EAAE,oBAAoB,KAAK,CAAC;AAC9D,MAAI,OAAO,SAAS,GAAG;AACrB,UAAM,UAAU,OAAO,IAAI,CAAC,UAAU,oBAAoB,MAAM,KAAK,CAAC,EAAE,KAAK,IAAI;AACjF,UAAM,IAAI,MAAM,GAAG,MAAM,sBAAsB,OAAO,IAAI;AAAA,EAC5D;AACA,SAAO;AACT;","names":[]}
|