@hublo/sentinel 1.4.0-alpha.30 → 1.4.0-alpha.32
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.js +4 -3
- package/dist/chunk-O7REVMOC.js +38 -0
- package/dist/chunk-O7REVMOC.js.map +1 -0
- package/dist/{chunk-GCZKG4B2.js → chunk-ORESQJAU.js} +330 -181
- package/dist/{chunk-Y2EKU6OE.js → chunk-SPW4KIQX.js} +1 -1
- package/dist/index.js +3 -2
- package/dist/roles/build/nest/toolchain.js +3 -26
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/{validate-ONAQPYJB.js → validate-5GABSOYH.js} +2 -1
- package/docs/test-adoption.md +51 -5
- package/docs/validating-a-change.md +35 -2
- package/package.json +1 -1
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.js
CHANGED
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-SPW4KIQX.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-ORESQJAU.js";
|
|
22
|
+
import "../chunk-O7REVMOC.js";
|
|
22
23
|
import {
|
|
23
24
|
ensureWorkspacePrep,
|
|
24
25
|
findWorkspaceRoot,
|
|
@@ -143,7 +144,7 @@ async function runValidate(ctx) {
|
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
if (!ctx.targets.includes("test")) return 1;
|
|
146
|
-
const { validateModuleTests } = await import("../validate-
|
|
147
|
+
const { validateModuleTests } = await import("../validate-5GABSOYH.js");
|
|
147
148
|
const outcomes = modules.flatMap(
|
|
148
149
|
(module) => validateModuleTests(module.name, module.root)
|
|
149
150
|
);
|
|
@@ -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":[]}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
matchesTsconfigPattern
|
|
3
|
+
} from "./chunk-O7REVMOC.js";
|
|
1
4
|
import {
|
|
2
5
|
WORKSPACE_ROOT_MARKER,
|
|
3
6
|
findWorkspaceRoot,
|
|
@@ -208,11 +211,11 @@ function readTestConfigSource(cwd, fileName) {
|
|
|
208
211
|
return "";
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
|
-
function
|
|
212
|
-
return
|
|
214
|
+
function importedTestPreset(source) {
|
|
215
|
+
return Object.entries(TEST_PRESET_SPECIFIERS).find(([, specifier]) => {
|
|
213
216
|
const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
214
217
|
return new RegExp(String.raw`(?:from|import)\s*\(?\s*['"]${escaped}['"]`).test(source);
|
|
215
|
-
});
|
|
218
|
+
})?.[0];
|
|
216
219
|
}
|
|
217
220
|
function readTestAdoption(cwd) {
|
|
218
221
|
const manifest = readProjectPackageJson(cwd);
|
|
@@ -254,11 +257,12 @@ function readTestAdoption(cwd) {
|
|
|
254
257
|
ownDeclarations
|
|
255
258
|
};
|
|
256
259
|
}
|
|
257
|
-
const
|
|
260
|
+
const preset = importedTestPreset(source);
|
|
261
|
+
const adopted = preset !== void 0;
|
|
258
262
|
const script = moduleScripts(cwd)["test"];
|
|
259
263
|
return {
|
|
260
264
|
configFile,
|
|
261
|
-
preset:
|
|
265
|
+
preset: preset ?? null,
|
|
262
266
|
adopted,
|
|
263
267
|
conformant: adopted && script !== void 0 && jestConfigs.length === 0,
|
|
264
268
|
drift: [],
|
|
@@ -558,15 +562,32 @@ function unusableAsBaseline(snapshot) {
|
|
|
558
562
|
}
|
|
559
563
|
return void 0;
|
|
560
564
|
}
|
|
565
|
+
var EXAMPLE_LIMIT = 60;
|
|
566
|
+
function oneLine(text) {
|
|
567
|
+
return text.replaceAll(/\s+/g, " ").trim();
|
|
568
|
+
}
|
|
569
|
+
function capped(text) {
|
|
570
|
+
if (text.length <= EXAMPLE_LIMIT) return text;
|
|
571
|
+
const half = Math.floor((EXAMPLE_LIMIT - 1) / 2);
|
|
572
|
+
return `${text.slice(0, half)}\u2026${text.slice(-half)}`;
|
|
573
|
+
}
|
|
561
574
|
function renamedExample(before, after) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
575
|
+
const from = oneLine(before);
|
|
576
|
+
const to = oneLine(after);
|
|
577
|
+
let head = 0;
|
|
578
|
+
while (head < from.length && head < to.length && from[head] === to[head]) head += 1;
|
|
579
|
+
const headBoundary = from.lastIndexOf(" ", head - 1);
|
|
580
|
+
const start = headBoundary > 0 ? headBoundary + 1 : 0;
|
|
581
|
+
let tail = 0;
|
|
582
|
+
const room = Math.min(from.length, to.length) - start;
|
|
583
|
+
while (tail < room && from.at(-1 - tail) === to.at(-1 - tail)) tail += 1;
|
|
584
|
+
const tailBoundary = from.indexOf(" ", from.length - tail);
|
|
585
|
+
const cut = tailBoundary > start ? from.length - tailBoundary : 0;
|
|
586
|
+
const lead = start > 0 ? "\u2026" : "";
|
|
587
|
+
const trail = cut > 0 ? "\u2026" : "";
|
|
588
|
+
const left = capped(from.slice(start, from.length - cut));
|
|
589
|
+
const right = capped(to.slice(start, to.length - cut));
|
|
590
|
+
return `"${lead}${left}${trail}" -> "${lead}${right}${trail}"`;
|
|
570
591
|
}
|
|
571
592
|
function describeComparison(comparison) {
|
|
572
593
|
const firstRename = comparison.renamed[0];
|
|
@@ -650,7 +671,10 @@ function captureReference(cwd, config) {
|
|
|
650
671
|
if (unusable !== void 0) {
|
|
651
672
|
return {
|
|
652
673
|
captured: false,
|
|
653
|
-
|
|
674
|
+
// The period matters: every `unusableAsBaseline` reason is a full sentence and none of them
|
|
675
|
+
// ends in one, so concatenating ran the cause straight into the consequence. Measured on
|
|
676
|
+
// `recruitment [functional]`: "…no claim to preserve The migration is applied anyway".
|
|
677
|
+
message: `no reference recorded: ${unusable}. The migration is applied anyway, unproved.`
|
|
654
678
|
};
|
|
655
679
|
}
|
|
656
680
|
const stored = {
|
|
@@ -899,9 +923,11 @@ function vitestSupportName(relative6) {
|
|
|
899
923
|
return `${directory}${VITEST_PREFIX}${base.slice(JEST_PREFIX.length)}`;
|
|
900
924
|
}
|
|
901
925
|
|
|
902
|
-
// src/roles/test/codemod/
|
|
903
|
-
import {
|
|
904
|
-
|
|
926
|
+
// src/roles/test/codemod/inject-type-import.ts
|
|
927
|
+
import { parseSync as parseSync3 } from "oxc-parser";
|
|
928
|
+
|
|
929
|
+
// src/roles/test/codemod/rename.ts
|
|
930
|
+
import { parseSync as parseSync2 } from "oxc-parser";
|
|
905
931
|
|
|
906
932
|
// src/shared/ast.ts
|
|
907
933
|
var SKIP = /* @__PURE__ */ Symbol("skip-subtree");
|
|
@@ -918,6 +944,186 @@ function walk(node, visit) {
|
|
|
918
944
|
}
|
|
919
945
|
}
|
|
920
946
|
|
|
947
|
+
// src/roles/test/codemod/rename.ts
|
|
948
|
+
var VITEST_TYPE_SOURCE = "vitest";
|
|
949
|
+
var RUNTIME_RENAMES = /* @__PURE__ */ new Set([
|
|
950
|
+
// Mocks
|
|
951
|
+
"clearAllMocks",
|
|
952
|
+
"doMock",
|
|
953
|
+
"fn",
|
|
954
|
+
"mock",
|
|
955
|
+
"mocked",
|
|
956
|
+
"resetAllMocks",
|
|
957
|
+
"resetModules",
|
|
958
|
+
"restoreAllMocks",
|
|
959
|
+
"spyOn",
|
|
960
|
+
"unmock",
|
|
961
|
+
/*
|
|
962
|
+
* Timers, as a family rather than as the two the corpus happens to use today. Running this over
|
|
963
|
+
* the repo reported `jest.runAllTimers` as unknown, which was a hole in the table rather than a
|
|
964
|
+
* finding about the code. Each name here was then checked against a running Vitest, so adding the
|
|
965
|
+
* siblings is not speculation: a file gaining one tomorrow gets the same answer as one using it
|
|
966
|
+
* today, instead of a report about a member that was always going to be a rename.
|
|
967
|
+
*/
|
|
968
|
+
"advanceTimersByTime",
|
|
969
|
+
"advanceTimersByTimeAsync",
|
|
970
|
+
"advanceTimersToNextTimer",
|
|
971
|
+
"clearAllTimers",
|
|
972
|
+
"getRealSystemTime",
|
|
973
|
+
"getTimerCount",
|
|
974
|
+
"runAllTimers",
|
|
975
|
+
"runAllTimersAsync",
|
|
976
|
+
"runOnlyPendingTimers",
|
|
977
|
+
"setSystemTime",
|
|
978
|
+
"useFakeTimers",
|
|
979
|
+
"useRealTimers"
|
|
980
|
+
]);
|
|
981
|
+
var TYPE_RENAMES = {
|
|
982
|
+
Mock: "Mock",
|
|
983
|
+
MockedClass: "MockedClass",
|
|
984
|
+
MockedFunction: "MockedFunction",
|
|
985
|
+
MockedObject: "MockedObject",
|
|
986
|
+
Mocked: "Mocked",
|
|
987
|
+
SpiedFunction: "MockInstance",
|
|
988
|
+
SpyInstance: "MockInstance"
|
|
989
|
+
};
|
|
990
|
+
var NAMED_RULE = {
|
|
991
|
+
requireActual: `await vi.importActual(...), which makes the surrounding factory async`,
|
|
992
|
+
requireMock: `await vi.importMock(...), which makes the surrounding factory async`,
|
|
993
|
+
setTimeout: `vi.setConfig({ testTimeout: ... }), which is configuration rather than a call`
|
|
994
|
+
};
|
|
995
|
+
var OWNED_BY_A_LATER_PASS = /* @__PURE__ */ new Set(["isolateModules", "isolateModulesAsync"]);
|
|
996
|
+
function lineOf(source, offset) {
|
|
997
|
+
let line = 1;
|
|
998
|
+
for (let index = 0; index < offset && index < source.length; index++) {
|
|
999
|
+
if (source[index] === "\n") line++;
|
|
1000
|
+
}
|
|
1001
|
+
return line;
|
|
1002
|
+
}
|
|
1003
|
+
function valueEdit(node, source, outcome) {
|
|
1004
|
+
const object = node.object;
|
|
1005
|
+
const property = node.property;
|
|
1006
|
+
if (object?.type !== "Identifier" || object.name !== "jest") return void 0;
|
|
1007
|
+
if (property?.type !== "Identifier") return void 0;
|
|
1008
|
+
const member = property.name;
|
|
1009
|
+
if (RUNTIME_RENAMES.has(member)) {
|
|
1010
|
+
outcome.rewritten++;
|
|
1011
|
+
return { start: object.start, end: object.end, text: "vi" };
|
|
1012
|
+
}
|
|
1013
|
+
if (OWNED_BY_A_LATER_PASS.has(member)) return void 0;
|
|
1014
|
+
outcome.unhandled.push({
|
|
1015
|
+
member: `jest.${member}`,
|
|
1016
|
+
line: lineOf(source, object.start),
|
|
1017
|
+
...NAMED_RULE[member] === void 0 ? {} : { rule: NAMED_RULE[member] }
|
|
1018
|
+
});
|
|
1019
|
+
return void 0;
|
|
1020
|
+
}
|
|
1021
|
+
function typeEdit(node, source, outcome, needed) {
|
|
1022
|
+
const left = node.left;
|
|
1023
|
+
const right = node.right;
|
|
1024
|
+
if (left?.type !== "Identifier" || left.name !== "jest") return void 0;
|
|
1025
|
+
if (right?.type !== "Identifier") return void 0;
|
|
1026
|
+
const member = right.name;
|
|
1027
|
+
const replacement = TYPE_RENAMES[member];
|
|
1028
|
+
if (replacement === void 0) {
|
|
1029
|
+
outcome.unhandled.push({ member: `jest.${member}`, line: lineOf(source, node.start) });
|
|
1030
|
+
return void 0;
|
|
1031
|
+
}
|
|
1032
|
+
needed.add(replacement);
|
|
1033
|
+
outcome.rewritten++;
|
|
1034
|
+
return { start: node.start, end: node.end, text: replacement };
|
|
1035
|
+
}
|
|
1036
|
+
function renameJestToVitest(fileName, source) {
|
|
1037
|
+
const outcome = { source, typeImports: [], rewritten: 0, unhandled: [] };
|
|
1038
|
+
const { program, errors } = parseSync2(fileName, source, { sourceType: "module" });
|
|
1039
|
+
if (errors.length > 0) return outcome;
|
|
1040
|
+
const edits = [];
|
|
1041
|
+
const needed = /* @__PURE__ */ new Set();
|
|
1042
|
+
walk(program, (node) => {
|
|
1043
|
+
if (node.type === "MemberExpression" || node.type === "StaticMemberExpression") {
|
|
1044
|
+
const edit = valueEdit(node, source, outcome);
|
|
1045
|
+
if (edit !== void 0) edits.push(edit);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (node.type === "TSQualifiedName") {
|
|
1049
|
+
const edit = typeEdit(node, source, outcome, needed);
|
|
1050
|
+
if (edit !== void 0) edits.push(edit);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
outcome.typeImports = [...needed].sort();
|
|
1054
|
+
let rewritten = source;
|
|
1055
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
1056
|
+
rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end);
|
|
1057
|
+
}
|
|
1058
|
+
outcome.source = rewritten;
|
|
1059
|
+
return outcome;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/roles/test/codemod/inject-type-import.ts
|
|
1063
|
+
function renderTypeImport(names, semi) {
|
|
1064
|
+
return `import type { ${names.join(", ")} } from '${VITEST_TYPE_SOURCE}'${semi ? ";" : ""}`;
|
|
1065
|
+
}
|
|
1066
|
+
function renderValueImport(semi) {
|
|
1067
|
+
return `import { vi } from '${VITEST_TYPE_SOURCE}'${semi ? ";" : ""}`;
|
|
1068
|
+
}
|
|
1069
|
+
function importDeclarations(program) {
|
|
1070
|
+
return (program.body ?? []).filter((node) => node.type === "ImportDeclaration");
|
|
1071
|
+
}
|
|
1072
|
+
function usesSemicolons(source, imports) {
|
|
1073
|
+
const first = imports[0];
|
|
1074
|
+
if (first === void 0) return false;
|
|
1075
|
+
return source.slice(first.start, first.end).trimEnd().endsWith(";");
|
|
1076
|
+
}
|
|
1077
|
+
function existingVitestImport(imports) {
|
|
1078
|
+
return imports.find(
|
|
1079
|
+
(node) => node.source?.value === VITEST_TYPE_SOURCE
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
function alreadyImported(declaration) {
|
|
1083
|
+
return (declaration.specifiers ?? []).filter((specifier) => specifier.type === "ImportSpecifier").map((specifier) => specifier.imported?.name).filter((name) => name !== void 0);
|
|
1084
|
+
}
|
|
1085
|
+
function injectTypeImport(fileName, source, typeNames) {
|
|
1086
|
+
if (typeNames.length === 0) return source;
|
|
1087
|
+
const { program, errors } = parseSync3(fileName, source, { sourceType: "module" });
|
|
1088
|
+
if (errors.length > 0) return source;
|
|
1089
|
+
const imports = importDeclarations(program);
|
|
1090
|
+
const semi = usesSemicolons(source, imports);
|
|
1091
|
+
const existing = existingVitestImport(imports);
|
|
1092
|
+
if (existing !== void 0) {
|
|
1093
|
+
const merged = [.../* @__PURE__ */ new Set([...alreadyImported(existing), ...typeNames])].sort();
|
|
1094
|
+
if (merged.length === alreadyImported(existing).length) return source;
|
|
1095
|
+
return source.slice(0, existing.start) + renderTypeImport(merged, semi) + source.slice(existing.end);
|
|
1096
|
+
}
|
|
1097
|
+
const line = renderTypeImport([...new Set(typeNames)].sort(), semi);
|
|
1098
|
+
const first = imports[0];
|
|
1099
|
+
if (first === void 0) return `${line}
|
|
1100
|
+
|
|
1101
|
+
${source}`;
|
|
1102
|
+
return source.slice(0, first.start) + line + "\n" + source.slice(first.start);
|
|
1103
|
+
}
|
|
1104
|
+
function injectViImport(fileName, source) {
|
|
1105
|
+
const { program, errors } = parseSync3(fileName, source, { sourceType: "module" });
|
|
1106
|
+
if (errors.length > 0) return source;
|
|
1107
|
+
const imports = importDeclarations(program);
|
|
1108
|
+
const existing = existingVitestImport(imports);
|
|
1109
|
+
if (existing !== void 0) {
|
|
1110
|
+
const named = (existing.specifiers ?? []).some(
|
|
1111
|
+
(specifier) => specifier.type === "ImportSpecifier" && specifier.imported?.name === "vi"
|
|
1112
|
+
);
|
|
1113
|
+
if (named && existing.importKind !== "type") return source;
|
|
1114
|
+
}
|
|
1115
|
+
const line = renderValueImport(usesSemicolons(source, imports));
|
|
1116
|
+
const first = imports[0];
|
|
1117
|
+
if (first === void 0) return `${line}
|
|
1118
|
+
|
|
1119
|
+
${source}`;
|
|
1120
|
+
return source.slice(0, first.start) + line + "\n" + source.slice(first.start);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/roles/test/codemod/migrate-file.ts
|
|
1124
|
+
import { dirname as dirname6 } from "node:path";
|
|
1125
|
+
import { parseSync as parseSync6 } from "oxc-parser";
|
|
1126
|
+
|
|
921
1127
|
// src/roles/test/codemod/assertion-return-value.ts
|
|
922
1128
|
var MEMBER_TYPES = /* @__PURE__ */ new Set(["MemberExpression", "StaticMemberExpression"]);
|
|
923
1129
|
function rootedAtExpect(node) {
|
|
@@ -1089,6 +1295,20 @@ function assertedMockedClass(source, node) {
|
|
|
1089
1295
|
if (object === void 0) return false;
|
|
1090
1296
|
return /\bMockedClass\b/.test(source.slice(object.start, object.end));
|
|
1091
1297
|
}
|
|
1298
|
+
function classValuedProperties(factories) {
|
|
1299
|
+
const found = [];
|
|
1300
|
+
for (const factory of factories) {
|
|
1301
|
+
walk(factory, (node) => {
|
|
1302
|
+
if (node.type !== "Property" && node.type !== "ObjectProperty") return;
|
|
1303
|
+
const key = node.key;
|
|
1304
|
+
const name = key?.type === "Identifier" ? key.name : key?.type === "StringLiteral" || key?.type === "Literal" ? String(key.value) : void 0;
|
|
1305
|
+
if (name === void 0 || !/^[A-Z]/.test(name)) return;
|
|
1306
|
+
const value = node.value;
|
|
1307
|
+
if (value !== void 0) found.push(value);
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
return found;
|
|
1311
|
+
}
|
|
1092
1312
|
function readsThis(node) {
|
|
1093
1313
|
let found = false;
|
|
1094
1314
|
walk(node, (inner) => {
|
|
@@ -1101,7 +1321,7 @@ function rewriteConstructibleMocks(source, program, factories) {
|
|
|
1101
1321
|
const refusals = [];
|
|
1102
1322
|
let rewritten = 0;
|
|
1103
1323
|
const seen = /* @__PURE__ */ new Set();
|
|
1104
|
-
const subtrees = [...factories];
|
|
1324
|
+
const subtrees = [...classValuedProperties(factories)];
|
|
1105
1325
|
walk(program, (node) => {
|
|
1106
1326
|
if (assertedMockedClass(source, node)) subtrees.push(node);
|
|
1107
1327
|
});
|
|
@@ -1660,165 +1880,6 @@ function rewriteHookReturnValue(source, program) {
|
|
|
1660
1880
|
return { source: result, rewritten: edits.length };
|
|
1661
1881
|
}
|
|
1662
1882
|
|
|
1663
|
-
// src/roles/test/codemod/inject-type-import.ts
|
|
1664
|
-
import { parseSync as parseSync3 } from "oxc-parser";
|
|
1665
|
-
|
|
1666
|
-
// src/roles/test/codemod/rename.ts
|
|
1667
|
-
import { parseSync as parseSync2 } from "oxc-parser";
|
|
1668
|
-
var VITEST_TYPE_SOURCE = "vitest";
|
|
1669
|
-
var RUNTIME_RENAMES = /* @__PURE__ */ new Set([
|
|
1670
|
-
// Mocks
|
|
1671
|
-
"clearAllMocks",
|
|
1672
|
-
"doMock",
|
|
1673
|
-
"fn",
|
|
1674
|
-
"mock",
|
|
1675
|
-
"mocked",
|
|
1676
|
-
"resetAllMocks",
|
|
1677
|
-
"resetModules",
|
|
1678
|
-
"restoreAllMocks",
|
|
1679
|
-
"spyOn",
|
|
1680
|
-
"unmock",
|
|
1681
|
-
/*
|
|
1682
|
-
* Timers, as a family rather than as the two the corpus happens to use today. Running this over
|
|
1683
|
-
* the repo reported `jest.runAllTimers` as unknown, which was a hole in the table rather than a
|
|
1684
|
-
* finding about the code. Each name here was then checked against a running Vitest, so adding the
|
|
1685
|
-
* siblings is not speculation: a file gaining one tomorrow gets the same answer as one using it
|
|
1686
|
-
* today, instead of a report about a member that was always going to be a rename.
|
|
1687
|
-
*/
|
|
1688
|
-
"advanceTimersByTime",
|
|
1689
|
-
"advanceTimersByTimeAsync",
|
|
1690
|
-
"advanceTimersToNextTimer",
|
|
1691
|
-
"clearAllTimers",
|
|
1692
|
-
"getRealSystemTime",
|
|
1693
|
-
"getTimerCount",
|
|
1694
|
-
"runAllTimers",
|
|
1695
|
-
"runAllTimersAsync",
|
|
1696
|
-
"runOnlyPendingTimers",
|
|
1697
|
-
"setSystemTime",
|
|
1698
|
-
"useFakeTimers",
|
|
1699
|
-
"useRealTimers"
|
|
1700
|
-
]);
|
|
1701
|
-
var TYPE_RENAMES = {
|
|
1702
|
-
Mock: "Mock",
|
|
1703
|
-
MockedClass: "MockedClass",
|
|
1704
|
-
MockedFunction: "MockedFunction",
|
|
1705
|
-
MockedObject: "MockedObject",
|
|
1706
|
-
Mocked: "Mocked",
|
|
1707
|
-
SpiedFunction: "MockInstance",
|
|
1708
|
-
SpyInstance: "MockInstance"
|
|
1709
|
-
};
|
|
1710
|
-
var NAMED_RULE = {
|
|
1711
|
-
requireActual: `await vi.importActual(...), which makes the surrounding factory async`,
|
|
1712
|
-
requireMock: `await vi.importMock(...), which makes the surrounding factory async`,
|
|
1713
|
-
setTimeout: `vi.setConfig({ testTimeout: ... }), which is configuration rather than a call`
|
|
1714
|
-
};
|
|
1715
|
-
var OWNED_BY_A_LATER_PASS = /* @__PURE__ */ new Set(["isolateModules", "isolateModulesAsync"]);
|
|
1716
|
-
function lineOf(source, offset) {
|
|
1717
|
-
let line = 1;
|
|
1718
|
-
for (let index = 0; index < offset && index < source.length; index++) {
|
|
1719
|
-
if (source[index] === "\n") line++;
|
|
1720
|
-
}
|
|
1721
|
-
return line;
|
|
1722
|
-
}
|
|
1723
|
-
function valueEdit(node, source, outcome) {
|
|
1724
|
-
const object = node.object;
|
|
1725
|
-
const property = node.property;
|
|
1726
|
-
if (object?.type !== "Identifier" || object.name !== "jest") return void 0;
|
|
1727
|
-
if (property?.type !== "Identifier") return void 0;
|
|
1728
|
-
const member = property.name;
|
|
1729
|
-
if (RUNTIME_RENAMES.has(member)) {
|
|
1730
|
-
outcome.rewritten++;
|
|
1731
|
-
return { start: object.start, end: object.end, text: "vi" };
|
|
1732
|
-
}
|
|
1733
|
-
if (OWNED_BY_A_LATER_PASS.has(member)) return void 0;
|
|
1734
|
-
outcome.unhandled.push({
|
|
1735
|
-
member: `jest.${member}`,
|
|
1736
|
-
line: lineOf(source, object.start),
|
|
1737
|
-
...NAMED_RULE[member] === void 0 ? {} : { rule: NAMED_RULE[member] }
|
|
1738
|
-
});
|
|
1739
|
-
return void 0;
|
|
1740
|
-
}
|
|
1741
|
-
function typeEdit(node, source, outcome, needed) {
|
|
1742
|
-
const left = node.left;
|
|
1743
|
-
const right = node.right;
|
|
1744
|
-
if (left?.type !== "Identifier" || left.name !== "jest") return void 0;
|
|
1745
|
-
if (right?.type !== "Identifier") return void 0;
|
|
1746
|
-
const member = right.name;
|
|
1747
|
-
const replacement = TYPE_RENAMES[member];
|
|
1748
|
-
if (replacement === void 0) {
|
|
1749
|
-
outcome.unhandled.push({ member: `jest.${member}`, line: lineOf(source, node.start) });
|
|
1750
|
-
return void 0;
|
|
1751
|
-
}
|
|
1752
|
-
needed.add(replacement);
|
|
1753
|
-
outcome.rewritten++;
|
|
1754
|
-
return { start: node.start, end: node.end, text: replacement };
|
|
1755
|
-
}
|
|
1756
|
-
function renameJestToVitest(fileName, source) {
|
|
1757
|
-
const outcome = { source, typeImports: [], rewritten: 0, unhandled: [] };
|
|
1758
|
-
const { program, errors } = parseSync2(fileName, source, { sourceType: "module" });
|
|
1759
|
-
if (errors.length > 0) return outcome;
|
|
1760
|
-
const edits = [];
|
|
1761
|
-
const needed = /* @__PURE__ */ new Set();
|
|
1762
|
-
walk(program, (node) => {
|
|
1763
|
-
if (node.type === "MemberExpression" || node.type === "StaticMemberExpression") {
|
|
1764
|
-
const edit = valueEdit(node, source, outcome);
|
|
1765
|
-
if (edit !== void 0) edits.push(edit);
|
|
1766
|
-
return;
|
|
1767
|
-
}
|
|
1768
|
-
if (node.type === "TSQualifiedName") {
|
|
1769
|
-
const edit = typeEdit(node, source, outcome, needed);
|
|
1770
|
-
if (edit !== void 0) edits.push(edit);
|
|
1771
|
-
}
|
|
1772
|
-
});
|
|
1773
|
-
outcome.typeImports = [...needed].sort();
|
|
1774
|
-
let rewritten = source;
|
|
1775
|
-
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
1776
|
-
rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end);
|
|
1777
|
-
}
|
|
1778
|
-
outcome.source = rewritten;
|
|
1779
|
-
return outcome;
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
// src/roles/test/codemod/inject-type-import.ts
|
|
1783
|
-
function renderTypeImport(names, semi) {
|
|
1784
|
-
return `import type { ${names.join(", ")} } from '${VITEST_TYPE_SOURCE}'${semi ? ";" : ""}`;
|
|
1785
|
-
}
|
|
1786
|
-
function importDeclarations(program) {
|
|
1787
|
-
return (program.body ?? []).filter((node) => node.type === "ImportDeclaration");
|
|
1788
|
-
}
|
|
1789
|
-
function usesSemicolons(source, imports) {
|
|
1790
|
-
const first = imports[0];
|
|
1791
|
-
if (first === void 0) return false;
|
|
1792
|
-
return source.slice(first.start, first.end).trimEnd().endsWith(";");
|
|
1793
|
-
}
|
|
1794
|
-
function existingVitestImport(imports) {
|
|
1795
|
-
return imports.find(
|
|
1796
|
-
(node) => node.source?.value === VITEST_TYPE_SOURCE
|
|
1797
|
-
);
|
|
1798
|
-
}
|
|
1799
|
-
function alreadyImported(declaration) {
|
|
1800
|
-
return (declaration.specifiers ?? []).filter((specifier) => specifier.type === "ImportSpecifier").map((specifier) => specifier.imported?.name).filter((name) => name !== void 0);
|
|
1801
|
-
}
|
|
1802
|
-
function injectTypeImport(fileName, source, typeNames) {
|
|
1803
|
-
if (typeNames.length === 0) return source;
|
|
1804
|
-
const { program, errors } = parseSync3(fileName, source, { sourceType: "module" });
|
|
1805
|
-
if (errors.length > 0) return source;
|
|
1806
|
-
const imports = importDeclarations(program);
|
|
1807
|
-
const semi = usesSemicolons(source, imports);
|
|
1808
|
-
const existing = existingVitestImport(imports);
|
|
1809
|
-
if (existing !== void 0) {
|
|
1810
|
-
const merged = [.../* @__PURE__ */ new Set([...alreadyImported(existing), ...typeNames])].sort();
|
|
1811
|
-
if (merged.length === alreadyImported(existing).length) return source;
|
|
1812
|
-
return source.slice(0, existing.start) + renderTypeImport(merged, semi) + source.slice(existing.end);
|
|
1813
|
-
}
|
|
1814
|
-
const line = renderTypeImport([...new Set(typeNames)].sort(), semi);
|
|
1815
|
-
const first = imports[0];
|
|
1816
|
-
if (first === void 0) return `${line}
|
|
1817
|
-
|
|
1818
|
-
${source}`;
|
|
1819
|
-
return source.slice(0, first.start) + line + "\n" + source.slice(first.start);
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
1883
|
// src/roles/test/codemod/named-rules.ts
|
|
1823
1884
|
import { existsSync as existsSync10, readFileSync as readFileSync7 } from "node:fs";
|
|
1824
1885
|
import { createRequire as createRequire2 } from "node:module";
|
|
@@ -1939,6 +2000,11 @@ function argumentText(source, call) {
|
|
|
1939
2000
|
const last = args[args.length - 1];
|
|
1940
2001
|
return source.slice(first.start, last.end);
|
|
1941
2002
|
}
|
|
2003
|
+
function typeArgumentText(source, call) {
|
|
2004
|
+
const node = call.typeArguments ?? call.typeParameters;
|
|
2005
|
+
if (node?.start === void 0 || node.end === void 0) return "";
|
|
2006
|
+
return source.slice(node.start, node.end);
|
|
2007
|
+
}
|
|
1942
2008
|
function applyEdits(source, edits) {
|
|
1943
2009
|
let result = source;
|
|
1944
2010
|
for (const edit of [...edits].sort((left, right) => right.start - left.start)) {
|
|
@@ -2033,7 +2099,10 @@ function applyNamedRules(source, program, fileName = "") {
|
|
|
2033
2099
|
edits.push({
|
|
2034
2100
|
start: node.start,
|
|
2035
2101
|
end: node.end,
|
|
2036
|
-
text: placed(
|
|
2102
|
+
text: placed(
|
|
2103
|
+
node,
|
|
2104
|
+
`await vi.${asyncReplacement}${typeArgumentText(source, node)}(${argumentText(source, node)})`
|
|
2105
|
+
)
|
|
2037
2106
|
});
|
|
2038
2107
|
applied.add(`jest.${member}`);
|
|
2039
2108
|
if (enclosing !== void 0 && enclosing.async !== true) asyncify.add(enclosing);
|
|
@@ -2951,6 +3020,7 @@ function rewriteSetupStaticImports(fileName, source, program, workspaceRoot) {
|
|
|
2951
3020
|
var SPY_TYPES = /* @__PURE__ */ new Set(["SpyInstance"]);
|
|
2952
3021
|
var QUALIFIED_ONLY = /* @__PURE__ */ new Set(["Mock"]);
|
|
2953
3022
|
var VITEST_NAME = { SpyInstance: "MockInstance", Mock: "Mock" };
|
|
3023
|
+
var MEMBER_TYPES16 = /* @__PURE__ */ new Set(["MemberExpression", "StaticMemberExpression"]);
|
|
2954
3024
|
function typeNameOf(source, node) {
|
|
2955
3025
|
const name = node.typeName;
|
|
2956
3026
|
if (name === void 0) return void 0;
|
|
@@ -2989,6 +3059,32 @@ function rewriteSpyInstanceType(source, program) {
|
|
|
2989
3059
|
typeImports: [...new Set(edits.map((edit) => edit.imports))]
|
|
2990
3060
|
};
|
|
2991
3061
|
}
|
|
3062
|
+
function rewriteMockFactoryGenerics(source, program) {
|
|
3063
|
+
const edits = [];
|
|
3064
|
+
walk(program, (node) => {
|
|
3065
|
+
if (node.type !== "CallExpression") return;
|
|
3066
|
+
const callee = node.callee;
|
|
3067
|
+
if (callee === void 0 || !MEMBER_TYPES16.has(callee.type)) return;
|
|
3068
|
+
const object = callee.object;
|
|
3069
|
+
const property = callee.property;
|
|
3070
|
+
if (object?.type !== "Identifier" || object.name !== "jest") return;
|
|
3071
|
+
if (property?.type !== "Identifier" || property.name !== "fn") return;
|
|
3072
|
+
const holder = node.typeArguments ?? node.typeParameters;
|
|
3073
|
+
const args = holder?.params;
|
|
3074
|
+
if (holder === void 0 || args === void 0 || args.length !== 2) return;
|
|
3075
|
+
const text = (argument) => source.slice(argument.start, argument.end);
|
|
3076
|
+
edits.push({
|
|
3077
|
+
start: holder.start,
|
|
3078
|
+
end: holder.end,
|
|
3079
|
+
text: `<(...args: ${text(args[1])}) => ${text(args[0])}>`
|
|
3080
|
+
});
|
|
3081
|
+
});
|
|
3082
|
+
let result = source;
|
|
3083
|
+
for (const edit of [...edits].sort((left, right) => right.start - left.start)) {
|
|
3084
|
+
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
|
|
3085
|
+
}
|
|
3086
|
+
return { source: result, rewritten: edits.length, typeImports: [] };
|
|
3087
|
+
}
|
|
2992
3088
|
|
|
2993
3089
|
// src/roles/test/codemod/migrate-file.ts
|
|
2994
3090
|
function mockFactories(program) {
|
|
@@ -3050,7 +3146,10 @@ function migrateTestFile(fileName, source, defaultImported = /* @__PURE__ */ new
|
|
|
3050
3146
|
const spyTypes = rewriteSpyInstanceType(tables.source, afterTables);
|
|
3051
3147
|
const afterSpyTypes = parse3(fileName, spyTypes.source);
|
|
3052
3148
|
if (afterSpyTypes === void 0) return unparsable("SpyInstance types");
|
|
3053
|
-
const
|
|
3149
|
+
const factoryGenerics = rewriteMockFactoryGenerics(spyTypes.source, afterSpyTypes);
|
|
3150
|
+
const afterFactoryGenerics = parse3(fileName, factoryGenerics.source);
|
|
3151
|
+
if (afterFactoryGenerics === void 0) return unparsable("mock factory generics");
|
|
3152
|
+
const ruled = applyNamedRules(factoryGenerics.source, afterFactoryGenerics, fileName);
|
|
3054
3153
|
const renamed = renameJestToVitest(fileName, ruled.source);
|
|
3055
3154
|
const afterRename = parse3(fileName, renamed.source);
|
|
3056
3155
|
if (afterRename === void 0) return unparsable("jest to vi rename");
|
|
@@ -3344,7 +3443,8 @@ function migrateModuleTests(cwd, setupFiles = []) {
|
|
|
3344
3443
|
}
|
|
3345
3444
|
files++;
|
|
3346
3445
|
renamed += migrated.renamed;
|
|
3347
|
-
|
|
3446
|
+
const contents = /\bvi\./.test(migrated.source) ? injectViImport(join15(cwd, renamedTo), migrated.source) : migrated.source;
|
|
3447
|
+
operations.push({ kind: "write", path: renamedTo, contents });
|
|
3348
3448
|
}
|
|
3349
3449
|
for (const relative6 of listTestFiles(cwd)) {
|
|
3350
3450
|
const source = read2(join15(cwd, relative6));
|
|
@@ -4205,6 +4305,51 @@ function shippedTypesPath(cwd, declaration) {
|
|
|
4205
4305
|
}
|
|
4206
4306
|
return `./node_modules/@hublo/sentinel/${declaration}`;
|
|
4207
4307
|
}
|
|
4308
|
+
function helpersStillCallingJest(cwd) {
|
|
4309
|
+
const found = [];
|
|
4310
|
+
const skip = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage", ".git", ".nx", ".vite"]);
|
|
4311
|
+
const walk2 = (dir) => {
|
|
4312
|
+
let entries;
|
|
4313
|
+
try {
|
|
4314
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
4315
|
+
} catch {
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
for (const entry of entries) {
|
|
4319
|
+
const path = join22(dir, entry.name);
|
|
4320
|
+
if (entry.isDirectory()) {
|
|
4321
|
+
if (!skip.has(entry.name)) walk2(path);
|
|
4322
|
+
continue;
|
|
4323
|
+
}
|
|
4324
|
+
if (!/\.[cm]?tsx?$/.test(entry.name)) continue;
|
|
4325
|
+
if (/[.-](spec|test)\.[cm]?[jt]sx?$/.test(entry.name)) continue;
|
|
4326
|
+
if (/^(jest|vitest)\./.test(entry.name)) continue;
|
|
4327
|
+
let source;
|
|
4328
|
+
try {
|
|
4329
|
+
source = readFileSync17(path, "utf8");
|
|
4330
|
+
} catch {
|
|
4331
|
+
continue;
|
|
4332
|
+
}
|
|
4333
|
+
if (/\bjest\./.test(source)) {
|
|
4334
|
+
found.push(relative5(cwd, path).replaceAll("\\", "/"));
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
};
|
|
4338
|
+
walk2(cwd);
|
|
4339
|
+
return found;
|
|
4340
|
+
}
|
|
4341
|
+
function compilesATestHelper(cwd, config) {
|
|
4342
|
+
const helpers = helpersStillCallingJest(cwd);
|
|
4343
|
+
if (helpers.length === 0) return false;
|
|
4344
|
+
const include = Array.isArray(config?.include) ? config.include : void 0;
|
|
4345
|
+
const exclude = Array.isArray(config?.exclude) ? config.exclude : [];
|
|
4346
|
+
if (include !== void 0 && include.length === 0) return false;
|
|
4347
|
+
return helpers.some((file) => {
|
|
4348
|
+
const included = include === void 0 || include.some((pattern) => matchesTsconfigPattern(pattern, file));
|
|
4349
|
+
if (!included) return false;
|
|
4350
|
+
return !exclude.some((pattern) => matchesTsconfigPattern(pattern, file));
|
|
4351
|
+
});
|
|
4352
|
+
}
|
|
4208
4353
|
function tsconfigFileListOperations(cwd) {
|
|
4209
4354
|
const operations = [];
|
|
4210
4355
|
let names;
|
|
@@ -4226,7 +4371,8 @@ function tsconfigFileListOperations(cwd) {
|
|
|
4226
4371
|
const include = Array.isArray(config?.include) ? rewriteFileList(config.include) : void 0;
|
|
4227
4372
|
const exclude = Array.isArray(config?.exclude) ? rewriteFileList(config.exclude) : void 0;
|
|
4228
4373
|
const files = Array.isArray(config?.files) ? rewriteFileList(config.files) : void 0;
|
|
4229
|
-
const
|
|
4374
|
+
const wantsTypes = name === SPEC_TSCONFIG || compilesATestHelper(cwd, config);
|
|
4375
|
+
const withTypes = wantsTypes ? withShippedTypes(cwd, files ?? config?.files) : void 0;
|
|
4230
4376
|
if (include === void 0 && exclude === void 0 && files === void 0 && withTypes === void 0)
|
|
4231
4377
|
continue;
|
|
4232
4378
|
operations.push({
|
|
@@ -4364,6 +4510,9 @@ function planJestMigration(context, adoption) {
|
|
|
4364
4510
|
`no style is declared here, by this module or its workspace, so what was written keeps the shape it was written in, and the Vitest type imports sit beside the existing ones rather than where your \`import/order\` wants them. That rule is classified by a prefix list this package cannot read, so it is left to the tool that owns it: run your module's own lint fixer (\`nx run <module>:lint --fix\` here) and its formatter before committing.`
|
|
4365
4511
|
);
|
|
4366
4512
|
}
|
|
4513
|
+
notes.push(
|
|
4514
|
+
`run \`pnpm install\` BEFORE the suite. This wrote \`@hublo/sentinel\` into the module's manifest and a config that imports subpaths of it, and a subpath only exists once that version is physically installed. Skip it and Vitest reports \`Cannot find module '<module>/@hublo/sentinel/test/setup/...'\`, which looks like a missing file rather than a missing install.`
|
|
4515
|
+
);
|
|
4367
4516
|
notes.push(
|
|
4368
4517
|
`then run this module's TYPECHECK, not only its tests. The migration rewrites types as well as calls, and a wrong type is invisible to a green suite: measured on a real module, \`--run --test\` passed while \`--run --typescript\` reported ten errors it had not had before.`
|
|
4369
4518
|
);
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,11 @@ import {
|
|
|
6
6
|
registerAdapters,
|
|
7
7
|
resolve,
|
|
8
8
|
setDefaultRunner
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-SPW4KIQX.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-ORESQJAU.js";
|
|
13
|
+
import "./chunk-O7REVMOC.js";
|
|
13
14
|
import "./chunk-NW6UHNYX.js";
|
|
14
15
|
export {
|
|
15
16
|
BaseAdapter,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
globToRegExp
|
|
3
|
+
} from "../../../chunk-O7REVMOC.js";
|
|
1
4
|
import {
|
|
2
5
|
decoratorMetadata,
|
|
3
6
|
tsconfigAliases
|
|
@@ -10,32 +13,6 @@ import { mergeConfig } from "vite";
|
|
|
10
13
|
// src/roles/build/nest/copy-assets.ts
|
|
11
14
|
import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
12
15
|
import { dirname, join, relative, sep } from "node:path";
|
|
13
|
-
function globToRegExp(glob) {
|
|
14
|
-
let pattern = "";
|
|
15
|
-
for (let index = 0; index < glob.length; index++) {
|
|
16
|
-
const char = glob[index] ?? "";
|
|
17
|
-
if (char === "*") {
|
|
18
|
-
if (glob[index + 1] === "*") {
|
|
19
|
-
pattern += glob[index + 2] === "/" ? "(?:.*/)?" : ".*";
|
|
20
|
-
index += glob[index + 2] === "/" ? 2 : 1;
|
|
21
|
-
} else {
|
|
22
|
-
pattern += "[^/]*";
|
|
23
|
-
}
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
if (char === "{") {
|
|
27
|
-
const close = glob.indexOf("}", index);
|
|
28
|
-
if (close === -1)
|
|
29
|
-
throw new Error(`sentinel build(nest): unbalanced \`{\` in asset glob ${glob}`);
|
|
30
|
-
const alternatives = glob.slice(index + 1, close).split(",");
|
|
31
|
-
pattern += `(?:${alternatives.map((one) => one.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`;
|
|
32
|
-
index = close;
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
pattern += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
-
}
|
|
37
|
-
return new RegExp(`^${pattern}$`);
|
|
38
|
-
}
|
|
39
16
|
function filesUnder(root) {
|
|
40
17
|
const found = [];
|
|
41
18
|
const walk = (dir) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/roles/build/nest/nest-service.ts","../../../../src/roles/build/nest/copy-assets.ts","../../../../src/roles/build/nest/node-manifest.ts","../../../../src/roles/build/nest/toolchain.ts"],"sourcesContent":["/**\n * The whole Vite config for a Nest service, so an adopted module holds two lines.\n *\n * ## Why a shape here, and pieces on the React side\n *\n * It looks inconsistent until you measure the two families. The four React apps each carry a\n * heavy, genuinely different config: one has a plugin wedged between two shared ones, another\n * has two different route-tree footers on the branches of one ternary. Two attempts at a\n * shared shape for them were abandoned, and the rule that survived is that sentinel rewrites\n * their IMPORTS and touches nothing else.\n *\n * The Nest side is the opposite by measurement: 38 services, one shared 25-line webpack config\n * between them, and nothing per-module to preserve. Owning the shape there is not a different\n * philosophy, it is the same one applied to a different fact: sentinel provides the FORM where\n * the form is shared, and the PIECES where it is not.\n *\n * The consequences are the point. An adopted module is readable in review. A correction to the\n * form is one publish instead of 38 diffs. And the day Nest becomes ESM-native, `format`\n * changes here rather than in 38 configs.\n *\n * ## It is a composition, not a block\n *\n * `nestService` assembles the same plugins this module exports individually. A service that\n * needs something else can pass `overrides`, or drop to the pieces entirely. And if the React\n * configs ever converge, a `reactApp()` can be built the same way with no new mechanism.\n */\nimport { join } from 'node:path'\n\nimport { mergeConfig, type UserConfig } from 'vite'\n\nimport { copyAssets } from './copy-assets.js'\nimport { decoratorMetadata } from './decorator-metadata.js'\nimport { nodeManifest } from './node-manifest.js'\nimport type { AssetDeclaration, TransformerDeclaration } from './nx-target.js'\nimport { tsconfigAliases } from './tsconfig-aliases.js'\n\nexport interface NestServiceOptions {\n /** The nx project name. Keys the dependency graph, and names the module in messages. */\n project: string\n /** Absolute path to the module's directory, normally `__dirname`. */\n root: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Entry point, relative to `root`. */\n entry?: string\n /**\n * The tsconfig whose emit must be preserved, when the service uses neither conventional name.\n *\n * Relative to `root`. Defaults to `tsconfig.app.json`, then `tsconfig.json`.\n */\n tsconfig?: string\n /** Output directory, relative to `workspaceRoot`. */\n outDir?: string\n /**\n * TypeScript transformers this service compiles with, as its build target declared them.\n *\n * Read off the webpack target by `--init` and written here, never inferred: what a service\n * compiles with is the service's own. Eight services and BFFs run `@nestjs/swagger/plugin`,\n * which writes the `@ApiProperty` decorators their DTOs do not declare by hand. Building\n * without it succeeds and silently costs them most of their published contract (measured on\n * `institution`: 385 insertions, 1378 deletions in the committed OpenAPI document).\n */\n transformers?: readonly TransformerDeclaration[]\n /**\n * Files copied into the output beside the bundle, in the shape `@nx/webpack` declared them.\n *\n * One service here uses it: `planning-period` ships the pug, css and ttf templates it renders\n * printed documents from. Without them the service starts and fails on its first print.\n */\n assets?: readonly AssetDeclaration[]\n /**\n * Anything this service needs that the shape does not give it.\n *\n * Merged with Vite's own `mergeConfig`, never with a merge of our own: plugins concatenate\n * and aliases stack the way Vite does it everywhere else, so there is no second set of\n * semantics to learn or to document.\n */\n overrides?: UserConfig\n}\n\nconst baseConfig = (options: NestServiceOptions): UserConfig => {\n const outDir = join(options.workspaceRoot, options.outDir ?? `dist/${options.project}`)\n const entry = join(options.root, options.entry ?? 'src/main.ts')\n return {\n plugins: [\n decoratorMetadata({\n root: options.root,\n tsconfig: options.tsconfig,\n transformers: options.transformers,\n }),\n ...(options.assets?.length\n ? [copyAssets({ workspaceRoot: options.workspaceRoot, outDir, assets: options.assets })]\n : []),\n nodeManifest({\n project: options.project,\n workspaceRoot: options.workspaceRoot,\n outDir,\n }),\n ],\n resolve: { alias: tsconfigAliases(options.workspaceRoot) },\n ssr: {\n // `importHelpers: true` in the workspace tsconfig makes TypeScript emit `require(\"tslib\")`\n // for `__decorate` and `__metadata`, so every decorated file depends on it. Hoisted into\n // one scope Rollup inlined it and nobody noticed; preserving modules left it external, and\n // the manifest nx generates from the project graph does not list it, because no module\n // DECLARES tslib. The image therefore did not install it and the service died on its first\n // require, only in the image: locally and in CI the workspace root has tslib.\n noExternal: ['tslib'],\n },\n build: {\n // An SSR build targets Node and leaves real packages external, which is what\n // `webpack-node-externals` did: the image installs them from the generated manifest.\n ssr: entry,\n outDir,\n emptyOutDir: true,\n sourcemap: true,\n target: 'node20',\n // The bundle is read by humans when a stack trace points into it, and minifying a\n // server bundle buys nothing: it is never downloaded.\n minify: false,\n rollupOptions: {\n output: {\n format: 'cjs',\n // One output file per source module, instead of hoisting everything into one scope.\n //\n // This is not a preference, it is the only shape that keeps the OpenAPI contract.\n // Merging every module into one scope forces Rollup to rename duplicate class names,\n // and `@nestjs/swagger` keys its schemas on `class.name`, so a renamed class silently\n // becomes a renamed schema in the published API. Measured on `network`, which has two\n // such collisions: single bundle renames `InvalidPermissionError` and\n // `PermissionNotFoundError`; preserving modules renames nothing and reproduces the\n // committed contract byte for byte.\n //\n // The cost is 4.5 MB across 2003 files against 2.9 MB in one, still well under\n // webpack's 6.9 MB for the same service, so nothing regresses against what it replaces.\n preserveModules: true,\n // The image runs `./dist/main.js`, so the entry keeps that name at the root while\n // every other module keeps its own path. Under `preserveModules` a plain string here\n // would be applied to all of them, which numbers them (`main962.js`) and loses the\n // entry.\n entryFileNames: (chunk) => (chunk.facadeModuleId === entry ? 'main.js' : '[name].js'),\n },\n // Nest registers metadata as an IMPORT SIDE EFFECT: a decorator writes into a catalog\n // when its module loads, and nothing references that module afterwards. Rollup may\n // drop such a module; webpack never did.\n //\n // Measured on the first migrated service this changes nothing, so it is insurance\n // rather than a fix, and it is recorded as such rather than credited with the smaller\n // bundle (that comes from barrel re-exports the service does not use).\n treeshake: { moduleSideEffects: true },\n },\n },\n }\n}\n\nexport const nestService = (options: NestServiceOptions): UserConfig =>\n options.overrides === undefined\n ? baseConfig(options)\n : mergeConfig(baseConfig(options), options.overrides)\n","/**\n * The files a service ships beside its bundle, which webpack copied and a bundler does not.\n *\n * `@nx/webpack` takes an `assets` list and copies each match into the output. Nothing in a Vite\n * build does that for a Node target: `publicDir` is a browser concept and is disabled for SSR\n * builds, so an unmigrated `assets` entry is not a smaller output, it is a service that starts\n * and then fails the first time it reaches for a file that is not there.\n *\n * One service in this repo declares it. `planning-period` ships the pug templates, stylesheet and\n * font it renders printed documents from, under `templates/`. Dropping them leaves a build that\n * succeeds, an image that boots, and a printing feature that throws.\n *\n * ## Copied at `closeBundle`, and only what the target declared\n *\n * The declaration is carried through from the webpack target unchanged, globs and all, so this\n * plugin has nothing to decide: it resolves each `input` against the workspace root, matches the\n * glob, and writes under `output` inside the bundle's directory. `closeBundle` because that is\n * when the output directory exists and Vite has finished emptying it.\n *\n * An entry matching nothing FAILS the build. A glob that has quietly stopped matching is the same\n * silent loss as no glob at all, and the point of this file is that this class of loss is loud.\n */\nimport { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'\nimport { dirname, join, relative, sep } from 'node:path'\n\nimport type { Plugin } from 'vite'\n\nimport type { AssetDeclaration } from './nx-target.js'\n\nexport interface CopyAssetsOptions {\n /** Absolute path to the workspace root, which `input` is relative to. */\n workspaceRoot: string\n /** Absolute path to the build's output directory. */\n outDir: string\n assets: readonly AssetDeclaration[]\n}\n\n/**\n * `@nx/webpack`'s glob dialect, reduced to what it means for a file name.\n *\n * Only the forms the declarations here use are supported, and anything else is rejected at build\n * time rather than silently matching nothing: `**` for any depth, `*` within a segment, and a\n * `{a,b}` alternation. Written out rather than pulled from a glob library because the whole\n * dependency would exist to read one line of configuration.\n */\nfunction 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)\n throw new Error(`sentinel build(nest): unbalanced \\`{\\` in asset 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/** Every file under `root`, as paths relative to it and with forward slashes. */\nfunction filesUnder(root: string): string[] {\n const found: string[] = []\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry)\n if (statSync(full).isDirectory()) walk(full)\n else found.push(relative(root, full).split(sep).join('/'))\n }\n }\n walk(root)\n return found\n}\n\n/** What each declaration copies, or the reason it cannot. */\nexport function resolveAssets(options: CopyAssetsOptions): { from: string; to: string }[] {\n const copies: { from: string; to: string }[] = []\n for (const asset of options.assets) {\n const input = join(options.workspaceRoot, asset.input)\n if (!existsSync(input)) {\n throw new Error(\n `sentinel build(nest): the build target declares an asset from \\`${asset.input}\\`, which ` +\n `does not exist. webpack copied it into the output, so the built service would be ` +\n `missing files it reads at runtime.`,\n )\n }\n const matcher = globToRegExp(asset.glob)\n const matched = filesUnder(input).filter((file) => matcher.test(file))\n if (matched.length === 0) {\n throw new Error(\n `sentinel build(nest): the asset glob \\`${asset.glob}\\` under \\`${asset.input}\\` matches ` +\n `no file. An entry that matches nothing is the same silent loss as no entry at all.`,\n )\n }\n for (const file of matched) {\n copies.push({ from: join(input, file), to: join(options.outDir, asset.output, file) })\n }\n }\n return copies\n}\n\nexport const copyAssets = (options: CopyAssetsOptions): Plugin => ({\n name: 'sentinel:copy-assets',\n // After the bundle is written and the output directory has stopped being emptied.\n closeBundle() {\n for (const { from, to } of resolveAssets(options)) {\n mkdirSync(dirname(to), { recursive: true })\n cpSync(from, to)\n }\n },\n})\n","/**\n * The pruned `package.json` and lockfile a Node image installs from.\n *\n * This is the half that made the nx executor look load-bearing. `--generatePackageJson` was an\n * option of `@nx/webpack:webpack`, and the backend image is built on its two outputs:\n *\n * COPY --from=build /app/dist/apps/nest/<service>/package.json /app/package.json\n * COPY --from=build /app/dist/apps/nest/<service>/pnpm-lock.yaml /app/pnpm-lock.yaml\n * RUN pnpm i --prod=true --frozen-lockfile\n *\n * It is not bound to the executor. The executor supplies one thing, the project graph, and\n * every function that does the work is exported by `@nx/js`. So the same two files can be\n * produced from a Vite plugin, and `--frozen-lockfile` accepting them afterwards is the proof\n * that they agree with each other: pnpm refuses the install otherwise.\n *\n * ## Why the graph, and not something simpler\n *\n * A Nest service here declares almost nothing of its own: measured, one dependency in its\n * manifest against 1463 installed in its image. Everything it uses is declared at the\n * workspace root and reaches it through source imports. So the dependency list can only be\n * computed from what the code imports, which is what the graph knows.\n *\n * Deriving it from the bundle's own externals is not equivalent either: measured on the first\n * service, the bundle statically requires 42 packages while the correct manifest holds 65. The\n * 23 missing ones are transitive or required dynamically at runtime (`fastify` behind\n * `@nestjs/platform-fastify`, `jsonwebtoken`, the LaunchDarkly SDK). Shipping the short list\n * fails in production, not in the build.\n */\nimport { existsSync, writeFileSync } from 'node:fs'\nimport { isBuiltin } from 'node:module'\nimport { join } from 'node:path'\n\nimport type { Plugin, Rollup } from 'vite'\n\nexport interface NodeManifestOptions {\n /** The nx project name, which is how the graph is keyed. */\n project: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Where the bundle is written; the two files land beside it. */\n outDir: string\n /** Entry file name, written as `main` so `node .` resolves inside the image. */\n entry?: string\n /**\n * Packages the IMAGE provides, which are therefore allowed to be required without being\n * declared in the manifest.\n *\n * The generated Prisma clients are the case this exists for: the Dockerfile copies\n * `node_modules/@prisma` from its own stage, so they resolve at runtime while no module\n * declares them. Everything else that is required and undeclared is a bug, and the check\n * below refuses the build.\n */\n providedByImage?: readonly string[]\n}\n\n/** What the Dockerfile copies in itself, so requiring it without declaring it is legitimate. */\nconst DEFAULT_PROVIDED_BY_IMAGE = ['@prisma'] as const\n\n/**\n * Every real package the emitted output requires, from Rollup's own record rather than a scan\n * of the text.\n *\n * `chunk.imports` mixes three things, and only the third is a dependency:\n *\n * - other EMITTED chunks. Under `preserveModules` these are relative output paths that do not\n * begin with `.`, so `apps/…`, `libs/…` and `_virtual/…` read exactly like package names.\n * They are recognised by being keys of the bundle itself.\n * - Node BUILTINS, which appear both bare (`crypto`) and prefixed (`node:crypto`).\n * - genuine external packages, which is what the manifest has to cover.\n */\nconst externalPackages = (bundle: Rollup.OutputBundle): Set<string> => {\n const emitted = new Set(Object.keys(bundle))\n const packages = new Set<string>()\n\n for (const emittedFile of Object.values(bundle)) {\n // Assets carry no imports, and the bundle holds both. Narrowing on the discriminant is what\n // replaced a cast through `unknown`, which asserted the same thing without checking it.\n if (emittedFile.type !== 'chunk') continue\n\n for (const imported of emittedFile.imports) {\n if (emitted.has(imported)) continue\n if (imported.startsWith('.') || imported.startsWith('/')) continue\n if (isBuiltin(imported)) continue\n\n const parts = imported.split('/')\n const name = imported.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]\n if (name) packages.add(name)\n }\n }\n return packages\n}\n\n/**\n * Writes the manifest and lockfile once the bundle is on disk.\n *\n * `closeBundle` rather than `writeBundle`, so the files land after Vite has finished with the\n * directory and cannot be cleared by `emptyOutDir`.\n *\n * `closeBundle` also runs after a FAILED build, where there is no directory to write into. Left\n * alone this hook then threw `ENOENT` on the manifest, and since it is the last error raised it\n * became the one Vite printed, hiding the failure that actually stopped the build. It cost two\n * diagnoses before being recognised, so the missing directory is now read as what it is: the\n * bundle was never written, and this plugin has nothing to say about why.\n */\nexport const nodeManifest = (options: NodeManifestOptions): Plugin => {\n /*\n * Captured while the bundle still exists in memory, and read back once the manifest is built.\n *\n * `undefined` until `generateBundle` runs, deliberately: an empty SET would make the coverage\n * check below pass by having seen nothing, which is the worst way for a guard to succeed. The\n * two states have to be told apart.\n */\n let required: Set<string> | undefined\n\n return {\n name: 'sentinel:node-manifest',\n apply: 'build',\n\n generateBundle(_outputOptions, bundle) {\n required = externalPackages(bundle)\n },\n\n async closeBundle() {\n // `closeBundle` runs after a FAILED build too. The ENTRY is what says a bundle was really\n // written: `emptyOutDir` leaves the directory in place, so its existence alone would let a\n // failed build still produce a manifest describing nothing.\n const entry = options.entry ?? 'main.js'\n if (!existsSync(join(options.outDir, entry))) return\n\n // Imported here rather than at module scope: a front config importing this file must not\n // pay for loading the nx graph machinery it will never call.\n const { createProjectGraphAsync } = await import('nx/src/devkit-exports.js')\n const { createPackageJson, createLockFile, getLockFileName } = await import('@nx/js')\n\n const graph = await createProjectGraphAsync({ exitOnError: false })\n const manifest = createPackageJson(options.project, graph, {\n root: options.workspaceRoot,\n isProduction: true,\n })\n manifest.main = entry\n\n if (required === undefined) {\n throw new Error(\n `sentinel build(nest): the bundle was written but never inspected, so the manifest ` +\n `could not be checked against what it requires. Refusing to write one that might ` +\n `not install.`,\n )\n }\n assertManifestCovers(required, manifest, options)\n\n writeFileSync(join(options.outDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\\n`)\n writeFileSync(\n join(options.outDir, getLockFileName('pnpm')),\n createLockFile(manifest, graph, 'pnpm'),\n )\n },\n }\n}\n\n/**\n * Refuse a bundle that requires something the image will not install.\n *\n * The manifest is derived from the project GRAPH, so it lists what modules declare. The bundle\n * requires what the bundler left external. Those two sets agreeing is an assumption, and it was\n * wrong: `tslib` arrives through `importHelpers` in the workspace tsconfig without any module\n * declaring it, so the manifest omitted it and the service died on its first require.\n *\n * It died only in the image. Locally and in CI the workspace root has the package, so the gap is\n * invisible until a container starts, which is the most expensive place to discover it. Checking\n * here turns that into a build failure naming the package.\n */\nconst assertManifestCovers = (\n required: ReadonlySet<string>,\n manifest: { dependencies?: Record<string, string> },\n options: NodeManifestOptions,\n): void => {\n const declared = new Set(Object.keys(manifest.dependencies ?? {}))\n const provided = options.providedByImage ?? DEFAULT_PROVIDED_BY_IMAGE\n const missing = [...required]\n .filter((name) => !declared.has(name))\n .filter((name) => !provided.some((prefix) => name === prefix || name.startsWith(`${prefix}/`)))\n .sort()\n\n if (missing.length === 0) return\n\n throw new Error(\n `sentinel build(nest): the bundle requires ${missing.length} package(s) the generated ` +\n `manifest does not declare, so the image would not install them: ${missing.join(', ')}. ` +\n `Either declare them in the module's package.json, inline them with ` +\n `\\`ssr.noExternal\\`, or list them in \\`providedByImage\\` if the Dockerfile copies them in.`,\n )\n}\n","/**\n * What `@hublo/sentinel/build/nest` gives an adopted service.\n *\n * The sibling `build/react` entry is a re-export surface: the module keeps its own config and\n * only its import lines move. This one carries a shape as well, because 38 Nest services share\n * one config and have nothing of their own to preserve. See `nest-service.ts` for why the two\n * answers differ, and why that is one rule rather than two.\n *\n * Its own entry point, like the React one, so importing it at build time never drags the CLI\n * and its adapters into a Vite config.\n */\nexport { nestService, type NestServiceOptions } from './nest-service.js'\n\n// The pieces the shape is built from, exported individually so a service that needs something\n// the shape does not give it can compose its own without leaving sentinel.\nexport { decoratorMetadata, type DecoratorMetadataOptions } from './decorator-metadata.js'\nexport { nodeManifest, type NodeManifestOptions } from './node-manifest.js'\nexport { tsconfigAliases, type Alias } from './tsconfig-aliases.js'\n\n// Vite's own, so an adopted config imports everything from one place and the module can drop\n// `vite` from its dependencies.\nexport { defineConfig, loadEnv, mergeConfig } from 'vite'\nexport type { Plugin, PluginOption, UserConfig, UserConfigExport } from 'vite'\n"],"mappings":"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","names":["join","existsSync","join","join","mergeConfig"]}
|
|
1
|
+
{"version":3,"sources":["../../../../src/roles/build/nest/nest-service.ts","../../../../src/roles/build/nest/copy-assets.ts","../../../../src/roles/build/nest/node-manifest.ts","../../../../src/roles/build/nest/toolchain.ts"],"sourcesContent":["/**\n * The whole Vite config for a Nest service, so an adopted module holds two lines.\n *\n * ## Why a shape here, and pieces on the React side\n *\n * It looks inconsistent until you measure the two families. The four React apps each carry a\n * heavy, genuinely different config: one has a plugin wedged between two shared ones, another\n * has two different route-tree footers on the branches of one ternary. Two attempts at a\n * shared shape for them were abandoned, and the rule that survived is that sentinel rewrites\n * their IMPORTS and touches nothing else.\n *\n * The Nest side is the opposite by measurement: 38 services, one shared 25-line webpack config\n * between them, and nothing per-module to preserve. Owning the shape there is not a different\n * philosophy, it is the same one applied to a different fact: sentinel provides the FORM where\n * the form is shared, and the PIECES where it is not.\n *\n * The consequences are the point. An adopted module is readable in review. A correction to the\n * form is one publish instead of 38 diffs. And the day Nest becomes ESM-native, `format`\n * changes here rather than in 38 configs.\n *\n * ## It is a composition, not a block\n *\n * `nestService` assembles the same plugins this module exports individually. A service that\n * needs something else can pass `overrides`, or drop to the pieces entirely. And if the React\n * configs ever converge, a `reactApp()` can be built the same way with no new mechanism.\n */\nimport { join } from 'node:path'\n\nimport { mergeConfig, type UserConfig } from 'vite'\n\nimport { copyAssets } from './copy-assets.js'\nimport { decoratorMetadata } from './decorator-metadata.js'\nimport { nodeManifest } from './node-manifest.js'\nimport type { AssetDeclaration, TransformerDeclaration } from './nx-target.js'\nimport { tsconfigAliases } from './tsconfig-aliases.js'\n\nexport interface NestServiceOptions {\n /** The nx project name. Keys the dependency graph, and names the module in messages. */\n project: string\n /** Absolute path to the module's directory, normally `__dirname`. */\n root: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Entry point, relative to `root`. */\n entry?: string\n /**\n * The tsconfig whose emit must be preserved, when the service uses neither conventional name.\n *\n * Relative to `root`. Defaults to `tsconfig.app.json`, then `tsconfig.json`.\n */\n tsconfig?: string\n /** Output directory, relative to `workspaceRoot`. */\n outDir?: string\n /**\n * TypeScript transformers this service compiles with, as its build target declared them.\n *\n * Read off the webpack target by `--init` and written here, never inferred: what a service\n * compiles with is the service's own. Eight services and BFFs run `@nestjs/swagger/plugin`,\n * which writes the `@ApiProperty` decorators their DTOs do not declare by hand. Building\n * without it succeeds and silently costs them most of their published contract (measured on\n * `institution`: 385 insertions, 1378 deletions in the committed OpenAPI document).\n */\n transformers?: readonly TransformerDeclaration[]\n /**\n * Files copied into the output beside the bundle, in the shape `@nx/webpack` declared them.\n *\n * One service here uses it: `planning-period` ships the pug, css and ttf templates it renders\n * printed documents from. Without them the service starts and fails on its first print.\n */\n assets?: readonly AssetDeclaration[]\n /**\n * Anything this service needs that the shape does not give it.\n *\n * Merged with Vite's own `mergeConfig`, never with a merge of our own: plugins concatenate\n * and aliases stack the way Vite does it everywhere else, so there is no second set of\n * semantics to learn or to document.\n */\n overrides?: UserConfig\n}\n\nconst baseConfig = (options: NestServiceOptions): UserConfig => {\n const outDir = join(options.workspaceRoot, options.outDir ?? `dist/${options.project}`)\n const entry = join(options.root, options.entry ?? 'src/main.ts')\n return {\n plugins: [\n decoratorMetadata({\n root: options.root,\n tsconfig: options.tsconfig,\n transformers: options.transformers,\n }),\n ...(options.assets?.length\n ? [copyAssets({ workspaceRoot: options.workspaceRoot, outDir, assets: options.assets })]\n : []),\n nodeManifest({\n project: options.project,\n workspaceRoot: options.workspaceRoot,\n outDir,\n }),\n ],\n resolve: { alias: tsconfigAliases(options.workspaceRoot) },\n ssr: {\n // `importHelpers: true` in the workspace tsconfig makes TypeScript emit `require(\"tslib\")`\n // for `__decorate` and `__metadata`, so every decorated file depends on it. Hoisted into\n // one scope Rollup inlined it and nobody noticed; preserving modules left it external, and\n // the manifest nx generates from the project graph does not list it, because no module\n // DECLARES tslib. The image therefore did not install it and the service died on its first\n // require, only in the image: locally and in CI the workspace root has tslib.\n noExternal: ['tslib'],\n },\n build: {\n // An SSR build targets Node and leaves real packages external, which is what\n // `webpack-node-externals` did: the image installs them from the generated manifest.\n ssr: entry,\n outDir,\n emptyOutDir: true,\n sourcemap: true,\n target: 'node20',\n // The bundle is read by humans when a stack trace points into it, and minifying a\n // server bundle buys nothing: it is never downloaded.\n minify: false,\n rollupOptions: {\n output: {\n format: 'cjs',\n // One output file per source module, instead of hoisting everything into one scope.\n //\n // This is not a preference, it is the only shape that keeps the OpenAPI contract.\n // Merging every module into one scope forces Rollup to rename duplicate class names,\n // and `@nestjs/swagger` keys its schemas on `class.name`, so a renamed class silently\n // becomes a renamed schema in the published API. Measured on `network`, which has two\n // such collisions: single bundle renames `InvalidPermissionError` and\n // `PermissionNotFoundError`; preserving modules renames nothing and reproduces the\n // committed contract byte for byte.\n //\n // The cost is 4.5 MB across 2003 files against 2.9 MB in one, still well under\n // webpack's 6.9 MB for the same service, so nothing regresses against what it replaces.\n preserveModules: true,\n // The image runs `./dist/main.js`, so the entry keeps that name at the root while\n // every other module keeps its own path. Under `preserveModules` a plain string here\n // would be applied to all of them, which numbers them (`main962.js`) and loses the\n // entry.\n entryFileNames: (chunk) => (chunk.facadeModuleId === entry ? 'main.js' : '[name].js'),\n },\n // Nest registers metadata as an IMPORT SIDE EFFECT: a decorator writes into a catalog\n // when its module loads, and nothing references that module afterwards. Rollup may\n // drop such a module; webpack never did.\n //\n // Measured on the first migrated service this changes nothing, so it is insurance\n // rather than a fix, and it is recorded as such rather than credited with the smaller\n // bundle (that comes from barrel re-exports the service does not use).\n treeshake: { moduleSideEffects: true },\n },\n },\n }\n}\n\nexport const nestService = (options: NestServiceOptions): UserConfig =>\n options.overrides === undefined\n ? baseConfig(options)\n : mergeConfig(baseConfig(options), options.overrides)\n","/**\n * The files a service ships beside its bundle, which webpack copied and a bundler does not.\n *\n * `@nx/webpack` takes an `assets` list and copies each match into the output. Nothing in a Vite\n * build does that for a Node target: `publicDir` is a browser concept and is disabled for SSR\n * builds, so an unmigrated `assets` entry is not a smaller output, it is a service that starts\n * and then fails the first time it reaches for a file that is not there.\n *\n * One service in this repo declares it. `planning-period` ships the pug templates, stylesheet and\n * font it renders printed documents from, under `templates/`. Dropping them leaves a build that\n * succeeds, an image that boots, and a printing feature that throws.\n *\n * ## Copied at `closeBundle`, and only what the target declared\n *\n * The declaration is carried through from the webpack target unchanged, globs and all, so this\n * plugin has nothing to decide: it resolves each `input` against the workspace root, matches the\n * glob, and writes under `output` inside the bundle's directory. `closeBundle` because that is\n * when the output directory exists and Vite has finished emptying it.\n *\n * An entry matching nothing FAILS the build. A glob that has quietly stopped matching is the same\n * silent loss as no glob at all, and the point of this file is that this class of loss is loud.\n */\nimport { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'\nimport { dirname, join, relative, sep } from 'node:path'\n\nimport type { Plugin } from 'vite'\n\nimport { globToRegExp } from '../../../shared/glob.js'\n\nimport type { AssetDeclaration } from './nx-target.js'\n\nexport interface CopyAssetsOptions {\n /** Absolute path to the workspace root, which `input` is relative to. */\n workspaceRoot: string\n /** Absolute path to the build's output directory. */\n outDir: string\n assets: readonly AssetDeclaration[]\n}\n\n/** Every file under `root`, as paths relative to it and with forward slashes. */\nfunction filesUnder(root: string): string[] {\n const found: string[] = []\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry)\n if (statSync(full).isDirectory()) walk(full)\n else found.push(relative(root, full).split(sep).join('/'))\n }\n }\n walk(root)\n return found\n}\n\n/** What each declaration copies, or the reason it cannot. */\nexport function resolveAssets(options: CopyAssetsOptions): { from: string; to: string }[] {\n const copies: { from: string; to: string }[] = []\n for (const asset of options.assets) {\n const input = join(options.workspaceRoot, asset.input)\n if (!existsSync(input)) {\n throw new Error(\n `sentinel build(nest): the build target declares an asset from \\`${asset.input}\\`, which ` +\n `does not exist. webpack copied it into the output, so the built service would be ` +\n `missing files it reads at runtime.`,\n )\n }\n const matcher = globToRegExp(asset.glob)\n const matched = filesUnder(input).filter((file) => matcher.test(file))\n if (matched.length === 0) {\n throw new Error(\n `sentinel build(nest): the asset glob \\`${asset.glob}\\` under \\`${asset.input}\\` matches ` +\n `no file. An entry that matches nothing is the same silent loss as no entry at all.`,\n )\n }\n for (const file of matched) {\n copies.push({ from: join(input, file), to: join(options.outDir, asset.output, file) })\n }\n }\n return copies\n}\n\nexport const copyAssets = (options: CopyAssetsOptions): Plugin => ({\n name: 'sentinel:copy-assets',\n // After the bundle is written and the output directory has stopped being emptied.\n closeBundle() {\n for (const { from, to } of resolveAssets(options)) {\n mkdirSync(dirname(to), { recursive: true })\n cpSync(from, to)\n }\n },\n})\n","/**\n * The pruned `package.json` and lockfile a Node image installs from.\n *\n * This is the half that made the nx executor look load-bearing. `--generatePackageJson` was an\n * option of `@nx/webpack:webpack`, and the backend image is built on its two outputs:\n *\n * COPY --from=build /app/dist/apps/nest/<service>/package.json /app/package.json\n * COPY --from=build /app/dist/apps/nest/<service>/pnpm-lock.yaml /app/pnpm-lock.yaml\n * RUN pnpm i --prod=true --frozen-lockfile\n *\n * It is not bound to the executor. The executor supplies one thing, the project graph, and\n * every function that does the work is exported by `@nx/js`. So the same two files can be\n * produced from a Vite plugin, and `--frozen-lockfile` accepting them afterwards is the proof\n * that they agree with each other: pnpm refuses the install otherwise.\n *\n * ## Why the graph, and not something simpler\n *\n * A Nest service here declares almost nothing of its own: measured, one dependency in its\n * manifest against 1463 installed in its image. Everything it uses is declared at the\n * workspace root and reaches it through source imports. So the dependency list can only be\n * computed from what the code imports, which is what the graph knows.\n *\n * Deriving it from the bundle's own externals is not equivalent either: measured on the first\n * service, the bundle statically requires 42 packages while the correct manifest holds 65. The\n * 23 missing ones are transitive or required dynamically at runtime (`fastify` behind\n * `@nestjs/platform-fastify`, `jsonwebtoken`, the LaunchDarkly SDK). Shipping the short list\n * fails in production, not in the build.\n */\nimport { existsSync, writeFileSync } from 'node:fs'\nimport { isBuiltin } from 'node:module'\nimport { join } from 'node:path'\n\nimport type { Plugin, Rollup } from 'vite'\n\nexport interface NodeManifestOptions {\n /** The nx project name, which is how the graph is keyed. */\n project: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Where the bundle is written; the two files land beside it. */\n outDir: string\n /** Entry file name, written as `main` so `node .` resolves inside the image. */\n entry?: string\n /**\n * Packages the IMAGE provides, which are therefore allowed to be required without being\n * declared in the manifest.\n *\n * The generated Prisma clients are the case this exists for: the Dockerfile copies\n * `node_modules/@prisma` from its own stage, so they resolve at runtime while no module\n * declares them. Everything else that is required and undeclared is a bug, and the check\n * below refuses the build.\n */\n providedByImage?: readonly string[]\n}\n\n/** What the Dockerfile copies in itself, so requiring it without declaring it is legitimate. */\nconst DEFAULT_PROVIDED_BY_IMAGE = ['@prisma'] as const\n\n/**\n * Every real package the emitted output requires, from Rollup's own record rather than a scan\n * of the text.\n *\n * `chunk.imports` mixes three things, and only the third is a dependency:\n *\n * - other EMITTED chunks. Under `preserveModules` these are relative output paths that do not\n * begin with `.`, so `apps/…`, `libs/…` and `_virtual/…` read exactly like package names.\n * They are recognised by being keys of the bundle itself.\n * - Node BUILTINS, which appear both bare (`crypto`) and prefixed (`node:crypto`).\n * - genuine external packages, which is what the manifest has to cover.\n */\nconst externalPackages = (bundle: Rollup.OutputBundle): Set<string> => {\n const emitted = new Set(Object.keys(bundle))\n const packages = new Set<string>()\n\n for (const emittedFile of Object.values(bundle)) {\n // Assets carry no imports, and the bundle holds both. Narrowing on the discriminant is what\n // replaced a cast through `unknown`, which asserted the same thing without checking it.\n if (emittedFile.type !== 'chunk') continue\n\n for (const imported of emittedFile.imports) {\n if (emitted.has(imported)) continue\n if (imported.startsWith('.') || imported.startsWith('/')) continue\n if (isBuiltin(imported)) continue\n\n const parts = imported.split('/')\n const name = imported.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]\n if (name) packages.add(name)\n }\n }\n return packages\n}\n\n/**\n * Writes the manifest and lockfile once the bundle is on disk.\n *\n * `closeBundle` rather than `writeBundle`, so the files land after Vite has finished with the\n * directory and cannot be cleared by `emptyOutDir`.\n *\n * `closeBundle` also runs after a FAILED build, where there is no directory to write into. Left\n * alone this hook then threw `ENOENT` on the manifest, and since it is the last error raised it\n * became the one Vite printed, hiding the failure that actually stopped the build. It cost two\n * diagnoses before being recognised, so the missing directory is now read as what it is: the\n * bundle was never written, and this plugin has nothing to say about why.\n */\nexport const nodeManifest = (options: NodeManifestOptions): Plugin => {\n /*\n * Captured while the bundle still exists in memory, and read back once the manifest is built.\n *\n * `undefined` until `generateBundle` runs, deliberately: an empty SET would make the coverage\n * check below pass by having seen nothing, which is the worst way for a guard to succeed. The\n * two states have to be told apart.\n */\n let required: Set<string> | undefined\n\n return {\n name: 'sentinel:node-manifest',\n apply: 'build',\n\n generateBundle(_outputOptions, bundle) {\n required = externalPackages(bundle)\n },\n\n async closeBundle() {\n // `closeBundle` runs after a FAILED build too. The ENTRY is what says a bundle was really\n // written: `emptyOutDir` leaves the directory in place, so its existence alone would let a\n // failed build still produce a manifest describing nothing.\n const entry = options.entry ?? 'main.js'\n if (!existsSync(join(options.outDir, entry))) return\n\n // Imported here rather than at module scope: a front config importing this file must not\n // pay for loading the nx graph machinery it will never call.\n const { createProjectGraphAsync } = await import('nx/src/devkit-exports.js')\n const { createPackageJson, createLockFile, getLockFileName } = await import('@nx/js')\n\n const graph = await createProjectGraphAsync({ exitOnError: false })\n const manifest = createPackageJson(options.project, graph, {\n root: options.workspaceRoot,\n isProduction: true,\n })\n manifest.main = entry\n\n if (required === undefined) {\n throw new Error(\n `sentinel build(nest): the bundle was written but never inspected, so the manifest ` +\n `could not be checked against what it requires. Refusing to write one that might ` +\n `not install.`,\n )\n }\n assertManifestCovers(required, manifest, options)\n\n writeFileSync(join(options.outDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\\n`)\n writeFileSync(\n join(options.outDir, getLockFileName('pnpm')),\n createLockFile(manifest, graph, 'pnpm'),\n )\n },\n }\n}\n\n/**\n * Refuse a bundle that requires something the image will not install.\n *\n * The manifest is derived from the project GRAPH, so it lists what modules declare. The bundle\n * requires what the bundler left external. Those two sets agreeing is an assumption, and it was\n * wrong: `tslib` arrives through `importHelpers` in the workspace tsconfig without any module\n * declaring it, so the manifest omitted it and the service died on its first require.\n *\n * It died only in the image. Locally and in CI the workspace root has the package, so the gap is\n * invisible until a container starts, which is the most expensive place to discover it. Checking\n * here turns that into a build failure naming the package.\n */\nconst assertManifestCovers = (\n required: ReadonlySet<string>,\n manifest: { dependencies?: Record<string, string> },\n options: NodeManifestOptions,\n): void => {\n const declared = new Set(Object.keys(manifest.dependencies ?? {}))\n const provided = options.providedByImage ?? DEFAULT_PROVIDED_BY_IMAGE\n const missing = [...required]\n .filter((name) => !declared.has(name))\n .filter((name) => !provided.some((prefix) => name === prefix || name.startsWith(`${prefix}/`)))\n .sort()\n\n if (missing.length === 0) return\n\n throw new Error(\n `sentinel build(nest): the bundle requires ${missing.length} package(s) the generated ` +\n `manifest does not declare, so the image would not install them: ${missing.join(', ')}. ` +\n `Either declare them in the module's package.json, inline them with ` +\n `\\`ssr.noExternal\\`, or list them in \\`providedByImage\\` if the Dockerfile copies them in.`,\n )\n}\n","/**\n * What `@hublo/sentinel/build/nest` gives an adopted service.\n *\n * The sibling `build/react` entry is a re-export surface: the module keeps its own config and\n * only its import lines move. This one carries a shape as well, because 38 Nest services share\n * one config and have nothing of their own to preserve. See `nest-service.ts` for why the two\n * answers differ, and why that is one rule rather than two.\n *\n * Its own entry point, like the React one, so importing it at build time never drags the CLI\n * and its adapters into a Vite config.\n */\nexport { nestService, type NestServiceOptions } from './nest-service.js'\n\n// The pieces the shape is built from, exported individually so a service that needs something\n// the shape does not give it can compose its own without leaving sentinel.\nexport { decoratorMetadata, type DecoratorMetadataOptions } from './decorator-metadata.js'\nexport { nodeManifest, type NodeManifestOptions } from './node-manifest.js'\nexport { tsconfigAliases, type Alias } from './tsconfig-aliases.js'\n\n// Vite's own, so an adopted config imports everything from one place and the module can drop\n// `vite` from its dependencies.\nexport { defineConfig, loadEnv, mergeConfig } from 'vite'\nexport type { Plugin, PluginOption, UserConfig, UserConfigExport } from 'vite'\n"],"mappings":"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","names":["join","existsSync","join","join","mergeConfig"]}
|
package/docs/test-adoption.md
CHANGED
|
@@ -10,6 +10,7 @@ that decided a design choice say which choice.
|
|
|
10
10
|
- [The one line that makes a per-module migration possible](#the-one-line-that-makes-a-per-module-migration-possible)
|
|
11
11
|
- [What `--init --test` rewrites, and what it refuses](#what---init---test-rewrites-and-what-it-refuses)
|
|
12
12
|
- [Nest services](#nest-services)
|
|
13
|
+
- [The two DOM matchers, which a react module gets without asking](#the-two-dom-matchers-which-a-react-module-gets-without-asking)
|
|
13
14
|
- [The check that decides whether you are done](#the-check-that-decides-whether-you-are-done)
|
|
14
15
|
|
|
15
16
|
## What the ground actually looks like
|
|
@@ -145,6 +146,30 @@ await expect(container).toBeValidHtml() // html-validate
|
|
|
145
146
|
|
|
146
147
|
A **nest** module gets neither, because there is no DOM to assert on.
|
|
147
148
|
|
|
149
|
+
⚠️ The generated config imports these by their package subpath, so they only resolve once
|
|
150
|
+
`pnpm install` has run. Skip that step and Vitest reports `Cannot find module
|
|
151
|
+
'<module>/@hublo/sentinel/test/setup/a11y'`, which names a path inside your module and reads like
|
|
152
|
+
a missing file rather than a missing install.
|
|
153
|
+
|
|
154
|
+
### What they found on the first module that ran them
|
|
155
|
+
|
|
156
|
+
`libs/front/components`, the design system, on the first run after migrating. The `Checkbox` given
|
|
157
|
+
a `label` had no accessible name at all, and both engines said so independently:
|
|
158
|
+
|
|
159
|
+
| engine | message |
|
|
160
|
+
| --------------- | ----------------------------------------------------------- |
|
|
161
|
+
| `axe-core` | `aria-prohibited-attr`: aria-label cannot be used on a span |
|
|
162
|
+
| `axe-core` | `label`: form elements must have labels |
|
|
163
|
+
| `html-validate` | `aria-label-misuse` |
|
|
164
|
+
| `html-validate` | `input-missing-label` |
|
|
165
|
+
|
|
166
|
+
One line caused it: `aria-label` was passed to the component, so MUI put it on the root `<span>`
|
|
167
|
+
rather than on the `<input>`. What makes it worth repeating here is why nothing had caught it. The
|
|
168
|
+
component is used across an app of several hundred screens, its tests were green, and
|
|
169
|
+
`getByLabelText` FOUND it: the query returned the wrapper span, so a test could locate the
|
|
170
|
+
checkbox, click it, and never notice it was announced with no name. The name was invisible to
|
|
171
|
+
everything except a screen reader and these two engines.
|
|
172
|
+
|
|
148
173
|
### What `toBeAccessible()` does and does not say
|
|
149
174
|
|
|
150
175
|
It runs axe on what you hand it and fails on any violation, naming the rule, the elements and the
|
|
@@ -268,16 +293,36 @@ member NAMES, which `any` gave up too. Applied only when the SAME file mocks tha
|
|
|
268
293
|
## The check that decides whether you are done
|
|
269
294
|
|
|
270
295
|
Not "the suite is green". **The suite runs the same tests as before**. You do not have to arrange
|
|
271
|
-
that comparison;
|
|
296
|
+
that comparison; `--validate` does it, on either side of the migration:
|
|
272
297
|
|
|
273
298
|
```console
|
|
274
|
-
$ sentinel --
|
|
275
|
-
$
|
|
276
|
-
$
|
|
299
|
+
$ sentinel --validate --test # on the JEST state: records every passing test name
|
|
300
|
+
$ sentinel --init --test # migrates
|
|
301
|
+
$ pnpm install # the module's dependencies changed
|
|
302
|
+
$ sentinel --validate --test # runs vitest, compares against that recording, then spends it
|
|
277
303
|
```
|
|
278
304
|
|
|
279
305
|
A codemod touching thousands of files cannot be reviewed by hand. This comparison is the review.
|
|
280
306
|
|
|
307
|
+
⚠️ **The first command comes first, and the tool enforces it.** Start with `--init` and the jest
|
|
308
|
+
config is already gone, so there is nothing left to record and nothing to compare against:
|
|
309
|
+
|
|
310
|
+
```console
|
|
311
|
+
✗ recruitment — already migrated, and no reference was recorded before it was, so there is
|
|
312
|
+
nothing to compare against. A proof has to be started BEFORE the migration:
|
|
313
|
+
`sentinel --validate --test` on the jest state, then `--init --test`, then this again.
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Why the proof is its own verb
|
|
317
|
+
|
|
318
|
+
Because of what SURVIVES a run. `--init` and `--migrate` write state that stays, which is their
|
|
319
|
+
whole purpose and why they are strict about what they touch. `--validate` may instrument the module
|
|
320
|
+
it is proving, provided nothing it writes remains. Those are different contracts, so they are
|
|
321
|
+
different verbs, and the proof can grow without touching a migration that is already proven.
|
|
322
|
+
|
|
323
|
+
It answers every type, too: `sentinel --validate --lint` is a well-formed command, so it says "not
|
|
324
|
+
available yet" rather than calling your sentence wrong.
|
|
325
|
+
|
|
281
326
|
### Why it cannot be one command
|
|
282
327
|
|
|
283
328
|
The reference only exists BEFORE the migration, and the thing that produces it, the jest config, is
|
|
@@ -297,7 +342,7 @@ shorter.
|
|
|
297
342
|
So the gate refuses a run that lost a name or invented one, and says which:
|
|
298
343
|
|
|
299
344
|
```console
|
|
300
|
-
$ sentinel --
|
|
345
|
+
$ sentinel --validate --test
|
|
301
346
|
1 test(s) no longer exist: useHublerNetworkProfilesQuery builds the URL with employment
|
|
302
347
|
statuses only. (reference: jest.config.ts, 2026-09-23T21:10:00.000Z) The reference is KEPT
|
|
303
348
|
until a run passes it, so this does not go green by running again. Fix the suite and re-run,
|
|
@@ -332,6 +377,7 @@ have read that improvement as a regression.
|
|
|
332
377
|
```console
|
|
333
378
|
$ nx run <module>:typecheck # BEFORE, and write that down too
|
|
334
379
|
$ sentinel --init --test
|
|
380
|
+
$ pnpm install
|
|
335
381
|
$ nx run <module>:typecheck # after
|
|
336
382
|
```
|
|
337
383
|
|
|
@@ -7,6 +7,7 @@ Everything here was learned the expensive way. None of it is theory.
|
|
|
7
7
|
- [The rule everything follows](#the-rule-everything-follows)
|
|
8
8
|
- [Phase 0: prove the premise](#phase-0-prove-the-premise)
|
|
9
9
|
- [Phase 1: measure the output](#phase-1-measure-the-output)
|
|
10
|
+
- [Measuring a migration, where both sides must be the same program](#measuring-a-migration-where-both-sides-must-be-the-same-program)
|
|
10
11
|
- [Traps that report success](#traps-that-report-success)
|
|
11
12
|
- [What CI does not cover](#what-ci-does-not-cover)
|
|
12
13
|
|
|
@@ -69,6 +70,34 @@ rm -rf apps/front/<x>/.output && nx run <x>:build # cache hit: same count re
|
|
|
69
70
|
`console` once restored **0 of 464** files from a green cache hit. Its image runs `pnpm` rather
|
|
70
71
|
than nx, so the one caller that would have failed never used the cache.
|
|
71
72
|
|
|
73
|
+
## Measuring a migration, where both sides must be the same program
|
|
74
|
+
|
|
75
|
+
A migration is proved by comparing a suite BEFORE against the same suite AFTER. That comparison is
|
|
76
|
+
only worth something if the two runs differ in the one variable being changed. Three ways they
|
|
77
|
+
silently differed here, each one costing hours of attributing a real-looking red to the wrong
|
|
78
|
+
cause.
|
|
79
|
+
|
|
80
|
+
**Run the module's OWN installed binary.** Invoking a local build by path (`node
|
|
81
|
+
/path/to/sentinel/dist/bin/sentinel.js`) resolves the runner through SENTINEL's manifest, so the
|
|
82
|
+
suite runs against the sentinel repo's physical install of vitest rather than the monorepo's. Two
|
|
83
|
+
red modules in one campaign were that and nothing else. A campaign runs `npx
|
|
84
|
+
@hublo/sentinel@<version>` or the module's own script, never a path.
|
|
85
|
+
|
|
86
|
+
**Run from the workspace ROOT.** The adapter relocates vitest to the root with `--root <module>`,
|
|
87
|
+
because a module's config resolves workspace aliases from there. Running the same command from
|
|
88
|
+
inside the module changes what resolves, and the failure looks like the migration's fault.
|
|
89
|
+
|
|
90
|
+
**Let the module's environment in, deliberately.** nx loads the repo's dotenv files into a task:
|
|
91
|
+
314 environment names under nx against 49 direct. One of them, set in a module's own `.env.local`,
|
|
92
|
+
made a single test fail, and the reference recorded without it reported a name that "disappeared".
|
|
93
|
+
This is the one place where the usual instinct, isolate, is WRONG: the two sides of a comparison
|
|
94
|
+
have to be the same program, so the proof loads the same files the module's own target loads.
|
|
95
|
+
|
|
96
|
+
**A name can leave both lists without failing.** Vitest reports the tests of a suite whose hook
|
|
97
|
+
threw as `skipped`, where jest counted them FAILED. The count moves, no test is red, and the
|
|
98
|
+
comparison must say "missing because skipped" rather than "lost", or it accuses the migration of
|
|
99
|
+
something the runner did.
|
|
100
|
+
|
|
72
101
|
## Traps that report success
|
|
73
102
|
|
|
74
103
|
| Trap | What it looks like | What closes it |
|
|
@@ -79,9 +108,13 @@ than nx, so the one caller that would have failed never used the cache.
|
|
|
79
108
|
| A clock reading vs an mtime | passes locally, fails in CI | stamp from the same filesystem |
|
|
80
109
|
| A stale cache entry | a hit that restores nothing | `nx reset`; entries predating a module's `outputs` restore nothing forever |
|
|
81
110
|
| `"$var:generate-x"` in zsh | a task name that does not exist | brace it: `"${var}:generate-x"` — `:g` is a zsh modifier |
|
|
111
|
+
| `rc=$?` after a pipeline | the status of `tail`, not yours | capture before piping, or `set -o pipefail` |
|
|
112
|
+
| `timeout` on macOS | an empty run read as green | it does not exist there; the command never ran |
|
|
113
|
+
| A local build by path | a red that is not the module's | resolves the runner through SENTINEL's install |
|
|
82
114
|
|
|
83
|
-
|
|
84
|
-
|
|
115
|
+
`rc=$?` after a pipeline caught us three times in one day, and once produced a claim we had
|
|
116
|
+
already announced. The zsh one is the only entry that lies toward FAILURE, which makes it visible
|
|
117
|
+
but sends you hunting for causes elsewhere.
|
|
85
118
|
|
|
86
119
|
## What CI does not cover
|
|
87
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hublo/sentinel",
|
|
3
|
-
"version": "1.4.0-alpha.
|
|
3
|
+
"version": "1.4.0-alpha.32",
|
|
4
4
|
"description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|