@r-machine/testing 1.0.0-alpha.11

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.
@@ -0,0 +1,384 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Sergio Turolla
4
+ *
5
+ * This file is part of r-machine, licensed under the
6
+ * GNU Affero General Public License v3.0 (AGPL-3.0-only).
7
+ *
8
+ * You may use, modify, and distribute this file under the terms
9
+ * of the AGPL-3.0. See LICENSE in this package for details.
10
+ *
11
+ * If you need to use this software in a proprietary project,
12
+ * contact: licensing@codecarvings.com
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ var __importDefault = (this && this.__importDefault) || function (mod) {
48
+ return (mod && mod.__esModule) ? mod : { "default": mod };
49
+ };
50
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
51
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
52
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
53
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
54
+ });
55
+ }
56
+ return path;
57
+ };
58
+ Object.defineProperty(exports, "__esModule", { value: true });
59
+ exports.verifyResourceAtlas = verifyResourceAtlas;
60
+ const node_path_1 = __importDefault(require("node:path"));
61
+ const node_url_1 = require("node:url");
62
+ const r_machine_1 = require("r-machine");
63
+ const core_1 = require("r-machine/core");
64
+ const errors_1 = require("r-machine/errors");
65
+ const errors_2 = require("#r-machine/testing/errors");
66
+ // ─── Cross-package dev-loader flag ──────────────────────────────────────
67
+ // Setting this symbol on `globalThis` signals tools like
68
+ // `@r-machine/next/createNextDevImport` to force their dev importer (jiti)
69
+ // active regardless of the usual `typeof window === "undefined"` gate.
70
+ // Lets `verifyResourceAtlas` run in jsdom environments (vitest's default
71
+ // for many web examples) without requiring the user to add the
72
+ // `// @vitest-environment node` pragma.
73
+ //
74
+ // Contract: the symbol is `Symbol.for(...)` (registry symbol) so identity
75
+ // is stable across realms and across packages without sharing code. Refer
76
+ // to `@r-machine/next/dev/create-next-dev-import.ts` for the consumer.
77
+ const FORCE_DEV_LOADER_FLAG = Symbol.for("@r-machine:force-dev-loader");
78
+ function acquireForceDevLoaderFlag() {
79
+ const slot = globalThis;
80
+ slot[FORCE_DEV_LOADER_FLAG] = (slot[FORCE_DEV_LOADER_FLAG] ?? 0) + 1;
81
+ }
82
+ function releaseForceDevLoaderFlag() {
83
+ const slot = globalThis;
84
+ const next = (slot[FORCE_DEV_LOADER_FLAG] ?? 0) - 1;
85
+ if (next <= 0) {
86
+ delete slot[FORCE_DEV_LOADER_FLAG];
87
+ }
88
+ else {
89
+ slot[FORCE_DEV_LOADER_FLAG] = next;
90
+ }
91
+ }
92
+ // ─── Cross-package observation flags (read from @r-machine/next) ────────
93
+ // `@r-machine/next`'s `createNextDevImport` writes these to signal whether
94
+ // the dev importer (jiti) was attempted and whether it activated. We reset
95
+ // them at the start of every verify call and inspect them at the end to
96
+ // emit an actionable hint when a Next setup is detected but jiti didn't
97
+ // start.
98
+ const DEV_LOADER_ATTEMPTED_FLAG = Symbol.for("@r-machine:dev-loader-attempted");
99
+ const DEV_LOADER_ENABLED_FLAG = Symbol.for("@r-machine:dev-loader-enabled");
100
+ function clearDevLoaderObservationFlags() {
101
+ const slot = globalThis;
102
+ delete slot[DEV_LOADER_ATTEMPTED_FLAG];
103
+ delete slot[DEV_LOADER_ENABLED_FLAG];
104
+ }
105
+ function isDevLoaderAttempted() {
106
+ return globalThis[DEV_LOADER_ATTEMPTED_FLAG] === true;
107
+ }
108
+ function isDevLoaderEnabled() {
109
+ return globalThis[DEV_LOADER_ENABLED_FLAG] === true;
110
+ }
111
+ function hasLoaderRelatedIssue(issues) {
112
+ return issues.some((i) => i.kind === "loader-error" || i.kind === "missing-resource" || i.kind === "invalid-module-shape");
113
+ }
114
+ async function verifyResourceAtlas(setupFile, options) {
115
+ const absoluteSetupFile = node_path_1.default.resolve(setupFile);
116
+ const strategyExportName = options?.strategyExportName ?? "strategy";
117
+ // ─── Static phase: extract atlas keys via TS Compiler API ──────────────
118
+ let extractedKeys;
119
+ try {
120
+ extractedKeys = await extractAtlasKeys(absoluteSetupFile, options?.tsconfig);
121
+ }
122
+ catch (err) {
123
+ return {
124
+ ok: false,
125
+ setupFile: absoluteSetupFile,
126
+ totalChecks: 0,
127
+ issues: [{ kind: "atlas-extraction-failed", reason: errorMessage(err) }],
128
+ };
129
+ }
130
+ // Reset observation flags so a previous verification in the same worker
131
+ // doesn't leak its "Next setup" signal into this one.
132
+ clearDevLoaderObservationFlags();
133
+ // Reference-counted: safe under concurrent `verifyResourceAtlas` calls in
134
+ // the same worker. Wraps both the runtime import of `setupFile` and the
135
+ // verification loop so any nested loader call also sees the flag.
136
+ acquireForceDevLoaderFlag();
137
+ try {
138
+ // ─── Runtime phase: import setup, reach the config via CONFIG_ACCESSOR ─
139
+ let config;
140
+ try {
141
+ const module = (await Promise.resolve(`${__rewriteRelativeImportExtension((0, node_url_1.pathToFileURL)(absoluteSetupFile).href)}`).then(s => __importStar(require(s))));
142
+ const strategy = module[strategyExportName];
143
+ if (strategy === undefined || strategy === null) {
144
+ throw new errors_1.RMachineUsageError(errors_2.ERR_VERIFY_SETUP_INVALID, `Setup file does not export "${strategyExportName}".`);
145
+ }
146
+ const accessor = strategy[r_machine_1.CONFIG_ACCESSOR];
147
+ if (typeof accessor !== "function") {
148
+ throw new errors_1.RMachineUsageError(errors_2.ERR_VERIFY_SETUP_INVALID, `Export "${strategyExportName}" does not expose CONFIG_ACCESSOR — make sure it is a r-machine Strategy or RMachine instance.`);
149
+ }
150
+ config = accessor.call(strategy);
151
+ }
152
+ catch (err) {
153
+ return {
154
+ ok: false,
155
+ setupFile: absoluteSetupFile,
156
+ totalChecks: 0,
157
+ issues: [{ kind: "config-access-failed", reason: errorMessage(err) }],
158
+ };
159
+ }
160
+ // ─── Verification phase: enumerate keys × locales ────────────────────
161
+ const resolver = new core_1.ResLayoutResolver(config.layout);
162
+ const issues = [];
163
+ let totalChecks = 0;
164
+ for (const extracted of extractedKeys) {
165
+ const { key, sourceLocation } = extracted;
166
+ // Atlas keys may start with `#` to mark them as internal (consumer-hidden).
167
+ // The runtime sees only the bare form — `getNamespace` strips the marker
168
+ // when building kit/priority/bridgeGears. Mirror that here so resolver and
169
+ // loader receive the same namespace shape they would in production. The
170
+ // original `#`-prefixed key is preserved in issue reports.
171
+ const namespace = stripInternalMarker(key);
172
+ let kind;
173
+ try {
174
+ kind = resolver.resolveLayoutEntryType(namespace);
175
+ }
176
+ catch (err) {
177
+ // Key declared in atlas but no layout entry covers its prefix.
178
+ totalChecks++;
179
+ issues.push({
180
+ kind: "loader-error",
181
+ key,
182
+ error: serializeError(err),
183
+ sourceLocation,
184
+ });
185
+ continue;
186
+ }
187
+ if (kind === "shell") {
188
+ for (const locale of config.locales) {
189
+ totalChecks++;
190
+ await runCheck(key, namespace, locale, kind, config, resolver, sourceLocation, issues);
191
+ }
192
+ }
193
+ else if (kind === "shell(mono)") {
194
+ totalChecks++;
195
+ await runCheck(key, namespace, config.defaultLocale, kind, config, resolver, sourceLocation, issues);
196
+ }
197
+ else {
198
+ totalChecks++;
199
+ await runCheck(key, namespace, undefined, kind, config, resolver, sourceLocation, issues);
200
+ }
201
+ }
202
+ // If we hit any loader-related issue inside a Next setup that didn't
203
+ // activate jiti, surface the most likely root cause so the user is not
204
+ // left chasing per-resource errors.
205
+ if (hasLoaderRelatedIssue(issues) && isDevLoaderAttempted() && !isDevLoaderEnabled()) {
206
+ issues.push({
207
+ kind: "dev-loader-not-active",
208
+ reason: "The @r-machine/next dev importer was invoked but jiti did not activate — most likely jiti is not installed. " +
209
+ "Install it as a dev dependency: `pnpm add -D jiti` (or the equivalent for your package manager).",
210
+ });
211
+ }
212
+ return {
213
+ ok: issues.length === 0,
214
+ setupFile: absoluteSetupFile,
215
+ totalChecks,
216
+ issues,
217
+ };
218
+ }
219
+ finally {
220
+ releaseForceDevLoaderFlag();
221
+ }
222
+ }
223
+ async function runCheck(key, namespace, locale, kind, config, resolver, sourceLocation, issues) {
224
+ const localePart = locale !== undefined ? { locale, isCanonical: locale === config.defaultLocale } : {};
225
+ const bareNamespace = namespace;
226
+ let modulePath;
227
+ let loaderOptions;
228
+ try {
229
+ modulePath = resolver.resolvePath(bareNamespace, locale, kind);
230
+ const namespaceParts = resolver.resolveNamespaceParts(bareNamespace);
231
+ const prefix = namespaceParts[0];
232
+ loaderOptions = {
233
+ namespace: bareNamespace,
234
+ namespaceParts,
235
+ pathParts: [prefix, modulePath.slice(prefix.length)],
236
+ locale,
237
+ };
238
+ }
239
+ catch (err) {
240
+ issues.push({
241
+ kind: "loader-error",
242
+ key,
243
+ ...localePart,
244
+ error: serializeError(err),
245
+ sourceLocation,
246
+ });
247
+ return;
248
+ }
249
+ let result;
250
+ try {
251
+ result = await config.load(modulePath, loaderOptions);
252
+ }
253
+ catch (err) {
254
+ issues.push({
255
+ kind: "loader-error",
256
+ key,
257
+ ...localePart,
258
+ error: serializeError(err),
259
+ sourceLocation,
260
+ });
261
+ return;
262
+ }
263
+ if (result === undefined || result === null) {
264
+ issues.push({
265
+ kind: "missing-resource",
266
+ key,
267
+ ...localePart,
268
+ sourceLocation,
269
+ });
270
+ return;
271
+ }
272
+ const validationError = (0, core_1.validateResModule)(result);
273
+ if (validationError) {
274
+ issues.push({
275
+ kind: "invalid-module-shape",
276
+ key,
277
+ ...localePart,
278
+ reason: validationError.message,
279
+ sourceLocation,
280
+ });
281
+ }
282
+ }
283
+ // ─── Static extraction via TS Compiler API ──────────────────────────────
284
+ async function extractAtlasKeys(setupFile, tsconfigPath) {
285
+ const tsModule = await loadTypeScript();
286
+ const compilerOptions = readCompilerOptions(tsModule, setupFile, tsconfigPath);
287
+ const program = tsModule.createProgram([setupFile], compilerOptions);
288
+ const checker = program.getTypeChecker();
289
+ const sourceFile = program.getSourceFile(setupFile);
290
+ if (!sourceFile) {
291
+ throw new errors_1.RMachineUsageError(errors_2.ERR_VERIFY_SETUP_INVALID, `Could not load source file: ${setupFile}`);
292
+ }
293
+ const atlasClass = findResourceAtlasClass(tsModule, sourceFile, checker, new Set());
294
+ if (!atlasClass) {
295
+ throw new errors_1.RMachineUsageError(errors_2.ERR_VERIFY_SETUP_INVALID, `Could not locate a "ResourceAtlas" class reachable from ${setupFile}. The check follows the import graph starting from the setup file.`);
296
+ }
297
+ const classType = checker.getTypeAtLocation(atlasClass);
298
+ const shapeProp = checker.getPropertiesOfType(classType).find((s) => s.name === "shape");
299
+ if (!shapeProp) {
300
+ throw new errors_1.RMachineUsageError(errors_2.ERR_VERIFY_SETUP_INVALID, `The "ResourceAtlas" class has no "shape" property — is it built from defineLayout()?`);
301
+ }
302
+ const shapeType = checker.getTypeOfSymbolAtLocation(shapeProp, atlasClass);
303
+ const result = [];
304
+ for (const prop of checker.getPropertiesOfType(shapeType)) {
305
+ const decl = prop.declarations?.[0];
306
+ const sourceLocation = decl ? extractSourceLocation(decl) : { file: setupFile, line: 0, column: 0 };
307
+ result.push({ key: prop.name, sourceLocation });
308
+ }
309
+ return result;
310
+ }
311
+ function findResourceAtlasClass(tsModule, sourceFile, checker, visited) {
312
+ if (visited.has(sourceFile.fileName))
313
+ return undefined;
314
+ visited.add(sourceFile.fileName);
315
+ let local;
316
+ tsModule.forEachChild(sourceFile, (node) => {
317
+ if (tsModule.isClassDeclaration(node) && node.name?.text === "ResourceAtlas") {
318
+ local = node;
319
+ }
320
+ });
321
+ if (local)
322
+ return local;
323
+ for (const stmt of sourceFile.statements) {
324
+ if (!tsModule.isImportDeclaration(stmt))
325
+ continue;
326
+ const moduleSpec = stmt.moduleSpecifier;
327
+ if (!tsModule.isStringLiteral(moduleSpec))
328
+ continue;
329
+ const moduleSymbol = checker.getSymbolAtLocation(moduleSpec);
330
+ const imported = moduleSymbol?.declarations?.[0]?.getSourceFile();
331
+ if (!imported)
332
+ continue;
333
+ const found = findResourceAtlasClass(tsModule, imported, checker, visited);
334
+ if (found)
335
+ return found;
336
+ }
337
+ return undefined;
338
+ }
339
+ function readCompilerOptions(tsModule, setupFile, tsconfigPath) {
340
+ const configPath = tsconfigPath ?? tsModule.findConfigFile(node_path_1.default.dirname(setupFile), tsModule.sys.fileExists, "tsconfig.json");
341
+ if (!configPath)
342
+ return {};
343
+ const configFile = tsModule.readConfigFile(configPath, tsModule.sys.readFile);
344
+ if (!configFile.config)
345
+ return {};
346
+ const parsed = tsModule.parseJsonConfigFileContent(configFile.config, tsModule.sys, node_path_1.default.dirname(configPath));
347
+ return parsed.options;
348
+ }
349
+ function extractSourceLocation(node) {
350
+ const sourceFile = node.getSourceFile();
351
+ const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart());
352
+ return {
353
+ file: sourceFile.fileName,
354
+ line: pos.line + 1,
355
+ column: pos.character + 1,
356
+ };
357
+ }
358
+ async function loadTypeScript() {
359
+ try {
360
+ return (await Promise.resolve().then(() => __importStar(require("typescript")))).default;
361
+ }
362
+ catch (err) {
363
+ throw new errors_1.RMachineUsageError(errors_2.ERR_VERIFY_SETUP_INVALID, `verifyResourceAtlas requires the "typescript" package as a peer dependency. Install it in your project.`, err instanceof Error ? err : undefined);
364
+ }
365
+ }
366
+ function stripInternalMarker(name) {
367
+ return name.charCodeAt(0) === 0x23 /* '#' */ ? name.slice(1) : name;
368
+ }
369
+ // ─── Error utilities ────────────────────────────────────────────────────
370
+ function errorMessage(err) {
371
+ if (err instanceof Error)
372
+ return err.message;
373
+ return String(err);
374
+ }
375
+ function serializeError(err) {
376
+ if (err instanceof Error) {
377
+ return {
378
+ name: err.name || "Error",
379
+ message: err.message,
380
+ ...(err.stack !== undefined ? { stack: err.stack } : {}),
381
+ };
382
+ }
383
+ return { name: "UnknownError", message: String(err) };
384
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Copyright (c) 2026 Sergio Turolla
3
+ *
4
+ * This file is part of r-machine, licensed under the
5
+ * GNU Affero General Public License v3.0 (AGPL-3.0-only).
6
+ *
7
+ * You may use, modify, and distribute this file under the terms
8
+ * of the AGPL-3.0. See LICENSE in this package for details.
9
+ *
10
+ * If you need to use this software in a proprietary project,
11
+ * contact: licensing@codecarvings.com
12
+ */
13
+ export type SourceLocation = {
14
+ file: string;
15
+ line: number;
16
+ column: number;
17
+ };
18
+ export type VerifyIssue = {
19
+ kind: "missing-resource";
20
+ key: string;
21
+ locale?: string;
22
+ isCanonical?: boolean;
23
+ sourceLocation?: SourceLocation;
24
+ } | {
25
+ kind: "loader-error";
26
+ key: string;
27
+ locale?: string;
28
+ isCanonical?: boolean;
29
+ error: {
30
+ name: string;
31
+ message: string;
32
+ stack?: string;
33
+ };
34
+ sourceLocation?: SourceLocation;
35
+ } | {
36
+ kind: "invalid-module-shape";
37
+ key: string;
38
+ locale?: string;
39
+ isCanonical?: boolean;
40
+ reason: string;
41
+ sourceLocation?: SourceLocation;
42
+ } | {
43
+ kind: "atlas-extraction-failed";
44
+ reason: string;
45
+ } | {
46
+ kind: "config-access-failed";
47
+ reason: string;
48
+ } | {
49
+ kind: "dev-loader-not-active";
50
+ reason: string;
51
+ };
52
+ export type VerifyReport = {
53
+ ok: boolean;
54
+ setupFile: string;
55
+ totalChecks: number;
56
+ issues: VerifyIssue[];
57
+ };
58
+ export type VerifyResourceAtlasOptions = {
59
+ /** Named export on the setup file that exposes the strategy. Default: "strategy". */
60
+ strategyExportName?: string;
61
+ /** Path to a tsconfig.json used for the static analysis pass. Defaults to the nearest one to setupFile. */
62
+ tsconfig?: string;
63
+ };
64
+ export declare function verifyResourceAtlas(setupFile: string, options?: VerifyResourceAtlasOptions): Promise<VerifyReport>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Copyright (c) 2026 Sergio Turolla
3
+ *
4
+ * This file is part of r-machine, licensed under the
5
+ * GNU Affero General Public License v3.0 (AGPL-3.0-only).
6
+ *
7
+ * You may use, modify, and distribute this file under the terms
8
+ * of the AGPL-3.0. See LICENSE in this package for details.
9
+ *
10
+ * If you need to use this software in a proprietary project,
11
+ * contact: licensing@codecarvings.com
12
+ */
13
+ export type SourceLocation = {
14
+ file: string;
15
+ line: number;
16
+ column: number;
17
+ };
18
+ export type VerifyIssue = {
19
+ kind: "missing-resource";
20
+ key: string;
21
+ locale?: string;
22
+ isCanonical?: boolean;
23
+ sourceLocation?: SourceLocation;
24
+ } | {
25
+ kind: "loader-error";
26
+ key: string;
27
+ locale?: string;
28
+ isCanonical?: boolean;
29
+ error: {
30
+ name: string;
31
+ message: string;
32
+ stack?: string;
33
+ };
34
+ sourceLocation?: SourceLocation;
35
+ } | {
36
+ kind: "invalid-module-shape";
37
+ key: string;
38
+ locale?: string;
39
+ isCanonical?: boolean;
40
+ reason: string;
41
+ sourceLocation?: SourceLocation;
42
+ } | {
43
+ kind: "atlas-extraction-failed";
44
+ reason: string;
45
+ } | {
46
+ kind: "config-access-failed";
47
+ reason: string;
48
+ } | {
49
+ kind: "dev-loader-not-active";
50
+ reason: string;
51
+ };
52
+ export type VerifyReport = {
53
+ ok: boolean;
54
+ setupFile: string;
55
+ totalChecks: number;
56
+ issues: VerifyIssue[];
57
+ };
58
+ export type VerifyResourceAtlasOptions = {
59
+ /** Named export on the setup file that exposes the strategy. Default: "strategy". */
60
+ strategyExportName?: string;
61
+ /** Path to a tsconfig.json used for the static analysis pass. Defaults to the nearest one to setupFile. */
62
+ tsconfig?: string;
63
+ };
64
+ export declare function verifyResourceAtlas(setupFile: string, options?: VerifyResourceAtlasOptions): Promise<VerifyReport>;