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