stonyx 0.2.3-alpha.48 → 0.2.3-alpha.49

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.
@@ -29,15 +29,24 @@ export declare const CONFIG_NOT_LOADABLE_PREFIX = "Config present but not loadab
29
29
  * instance of it is the exact `.ts`-under-node_modules case this loader's own
30
30
  * docs tell people to look for.
31
31
  *
32
- * Returns true when the refusal names our path, or when no path could be
33
- * extracted at all (unknown message shape — keep re-framing rather than
34
- * silently losing the loud error that invariant I2 exists for).
32
+ * FAIL DIRECTION — this is the property, not an implementation detail.
33
+ * `true` means "re-frame this as a refusal of the config", which is exactly the
34
+ * F-3 misdirection above. So when no path can be extracted this returns
35
+ * `false`, and the caller rethrows Node's own error untouched, `cause` and all.
36
+ * The consumer then reads what actually happened instead of a friendly sentence
37
+ * about the wrong file. This is what makes every future gap in the two patterns
38
+ * harmless: an unparseable message costs the I2 re-framing, not correctness.
39
+ *
40
+ * It previously returned `true` here, on the reasoning that keeping the loud
41
+ * re-frame mattered more than getting the filename right. Measured, that is
42
+ * backwards: the raw error is loud too — `main.ts`'s `NODE_ENV=test` swallow
43
+ * only absorbs `CONFIG_NOT_FOUND_PREFIX`, which a Node refusal never starts
44
+ * with — so failing this way loses nothing and stops naming the wrong file.
35
45
  *
36
46
  * Exported ONLY so its branches can be reached directly. Driving it through
37
- * `importConfig` reaches the two path-comparison outcomes and nothing else:
38
- * the "no path extractable" fallback and the `realPath` catch both survived
39
- * mutation with the suite green. A predicate inside the guard that makes
40
- * failures loud does not get to have branches no test can reach.
47
+ * `importConfig` reaches the two path-comparison outcomes and nothing else.
48
+ * A predicate inside the guard that makes failures loud does not get to have
49
+ * branches no test can reach.
41
50
  */
42
51
  export declare function refusalIsAboutTheConfig(message: string, configPath: string): boolean;
43
52
  export declare function importConfig<T = unknown>(basePath: string): Promise<T>;
@@ -67,10 +67,10 @@ const FILE_TYPE_REFUSAL_CODES = new Set([
67
67
  'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING',
68
68
  ]);
69
69
  /**
70
- * The first absolute POSIX path named in a Node error message.
70
+ * The absolute POSIX path a Node refusal names, in the two shapes it names it.
71
71
  *
72
- * Both refusal codes name the file they refused, but in different shapes —
73
- * measured on node v24.13.0:
72
+ * Both refusal codes name the file they refused, but differently — measured on
73
+ * node v24.13.0 at this head:
74
74
  *
75
75
  * ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
76
76
  * Stripping types is currently unsupported for files under node_modules,
@@ -78,16 +78,46 @@ const FILE_TYPE_REFUSAL_CODES = new Set([
78
78
  * ERR_UNKNOWN_FILE_EXTENSION
79
79
  * Unknown file extension ".ts" for /private/tmp/x/environment.ts
80
80
  *
81
- * The second names a bare extension BEFORE it names the file, which is why
82
- * this requires a leading `/` rather than matching the first quoted token.
83
- * Neither error exposes the path as an own property: `Object.keys(error)` is
84
- * `['code']` for both, so the message is the only source.
81
+ * In BOTH shapes the path is the last thing in the message, and both patterns
82
+ * are GREEDY, so each runs to the message tail rather than being bounded by a
83
+ * character class on the path body: the quoted one to the message-final quote,
84
+ * the bare one to the last `.ext` in the message. That is the whole mechanism —
85
+ * measured, an explicit `\s*$` anchor on either one is an equivalent mutant,
86
+ * so it is not there. The previous single pattern instead excluded space, `(`,
87
+ * `)`, `'` and `"` from the body,
88
+ * which meant a config under `~/My Project/` either failed extraction outright
89
+ * or backtracked onto an earlier `.` and yielded a truncated non-path.
90
+ * Measured end-to-end through `dist/` before this change, two consumers one
91
+ * character apart, each with a good `.js` config that nested-imports a `.ts`
92
+ * under `node_modules`:
85
93
  *
86
- * Deliberately POSIX-only. A Windows path is simply not extracted, and
87
- * `refusalIsAboutTheConfig` treats "no path extracted" as "cannot tell" and
88
- * keeps the pre-existing behaviour, so this degrades rather than misfires.
94
+ * .../plain/config/environment.js -> raw ERR_UNSUPPORTED_...
95
+ * naming node_modules/dep/thing.ts
96
+ * .../My Project/config/environment.js -> "Config present but not loadable:
97
+ * .../My Project/config/environment.js"
98
+ *
99
+ * The second names a `.js` that loaded and ran perfectly. Anchoring on the
100
+ * tail extracts both, and the quoted form tolerates quotes inside the path
101
+ * because it runs to the message-final quote rather than to the first one.
102
+ *
103
+ * `(?:file:\/\/)?` is not defensive padding. Node names the refused file as a
104
+ * `file://` URL whenever the package scope containing it declares a `type`,
105
+ * and as a bare path when it does not -- one function
106
+ * (`stripTypeScriptModuleTypes`) reached from two call sites,
107
+ * `internal/modules/esm/translators:654,663` with the URL and
108
+ * `internal/modules/esm/get_format:148` with `fileURLToPath(url)`. Scope
109
+ * lookup stops at `node_modules`, so for `src/modules.ts:103` the deciding
110
+ * file is the MODULE's `package.json` -- and every `@stonyx/*` module declares
111
+ * `type: 'module'`, as does the app `src/cli/new.ts:81` scaffolds. The URL
112
+ * shape is the default case. See `test/unit/import-config-test.ts` for the
113
+ * measured matrix.
114
+ *
115
+ * Still POSIX-only, and still not exhaustive — a Windows path is not extracted,
116
+ * and neither is a message with trailing text after the path. That is now SAFE
117
+ * rather than merely degraded: see `refusalIsAboutTheConfig`.
89
118
  */
90
- const REFUSED_PATH_PATTERN = /(?:^|[\s"'(])((?:file:\/\/)?\/[^\s"'()]+\.[A-Za-z0-9]+)/;
119
+ const QUOTED_REFUSED_PATH = /["']((?:file:\/\/)?\/.+)["']/;
120
+ const BARE_REFUSED_PATH = /(?:^|\s)((?:file:\/\/)?\/.+\.[A-Za-z0-9]+)/;
91
121
  /** Resolves symlinks so `/tmp/...` and `/private/tmp/...` compare equal. */
92
122
  function realPath(path) {
93
123
  try {
@@ -97,6 +127,49 @@ function realPath(path) {
97
127
  return path;
98
128
  }
99
129
  }
130
+ /**
131
+ * The path a refusal names, or `null` when no path can be extracted from it.
132
+ *
133
+ * `fileURLToPath` is contained for the same reason `realPath` is: this runs
134
+ * INSIDE `importConfig`'s catch handler, so anything it throws replaces the
135
+ * refusal being handled and takes its `cause` with it — the exact loss
136
+ * invariant I2 exists to prevent. Regex-matchable inputs that throw exist and
137
+ * were measured at this head: `file:///a%2Fb.ts` -> `ERR_INVALID_FILE_URL_PATH`,
138
+ * `file:///%.ts` and `file:///a%zz.ts` -> bare `URIError: URI malformed`.
139
+ *
140
+ * Node's own emissions never produce them — `pathToFileURL` percent-encodes a
141
+ * literal `%` and never emits a bare `%2F` for a POSIX filename — but the
142
+ * message is only USUALLY Node's. `refusalIsAboutTheConfig` is exported public
143
+ * API with no input validation, and a `register()` / `--import` loader hook
144
+ * (this suite itself runs under `tsx`) can throw `ERR_UNKNOWN_FILE_EXTENSION`
145
+ * with any message it likes. Driven end-to-end through `dist/` with such a
146
+ * hook before this containment, the consumer got `ERR_INVALID_FILE_URL_PATH:
147
+ * File URL path must not include encoded / characters` with `cause` undefined,
148
+ * and the refusal it was actually handling was gone.
149
+ *
150
+ * An unparseable URL returns `null`, which the predicate reads as "cannot
151
+ * tell" -> `false` -> the original error propagates untouched. Same fail
152
+ * direction as every other unknown in here.
153
+ *
154
+ * Quoted shape first: it is the more specific of the two. Measured at this
155
+ * head the order is an EQUIVALENT mutant — swapping it leaves the suite at
156
+ * 130/0, because neither measured message shape matches both patterns — so the
157
+ * order is a statement of intent, not a load-bearing branch. Recorded here so
158
+ * the next mutation run does not re-chase it.
159
+ */
160
+ function refusedPathFrom(message) {
161
+ const refused = QUOTED_REFUSED_PATH.exec(message)?.[1] ?? BARE_REFUSED_PATH.exec(message)?.[1];
162
+ if (!refused)
163
+ return null;
164
+ if (!refused.startsWith('file://'))
165
+ return refused;
166
+ try {
167
+ return fileURLToPath(refused);
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
100
173
  /**
101
174
  * Did this runtime refuse OUR config file, or something our config imported?
102
175
  *
@@ -108,21 +181,29 @@ function realPath(path) {
108
181
  * instance of it is the exact `.ts`-under-node_modules case this loader's own
109
182
  * docs tell people to look for.
110
183
  *
111
- * Returns true when the refusal names our path, or when no path could be
112
- * extracted at all (unknown message shape — keep re-framing rather than
113
- * silently losing the loud error that invariant I2 exists for).
184
+ * FAIL DIRECTION — this is the property, not an implementation detail.
185
+ * `true` means "re-frame this as a refusal of the config", which is exactly the
186
+ * F-3 misdirection above. So when no path can be extracted this returns
187
+ * `false`, and the caller rethrows Node's own error untouched, `cause` and all.
188
+ * The consumer then reads what actually happened instead of a friendly sentence
189
+ * about the wrong file. This is what makes every future gap in the two patterns
190
+ * harmless: an unparseable message costs the I2 re-framing, not correctness.
191
+ *
192
+ * It previously returned `true` here, on the reasoning that keeping the loud
193
+ * re-frame mattered more than getting the filename right. Measured, that is
194
+ * backwards: the raw error is loud too — `main.ts`'s `NODE_ENV=test` swallow
195
+ * only absorbs `CONFIG_NOT_FOUND_PREFIX`, which a Node refusal never starts
196
+ * with — so failing this way loses nothing and stops naming the wrong file.
114
197
  *
115
198
  * Exported ONLY so its branches can be reached directly. Driving it through
116
- * `importConfig` reaches the two path-comparison outcomes and nothing else:
117
- * the "no path extractable" fallback and the `realPath` catch both survived
118
- * mutation with the suite green. A predicate inside the guard that makes
119
- * failures loud does not get to have branches no test can reach.
199
+ * `importConfig` reaches the two path-comparison outcomes and nothing else.
200
+ * A predicate inside the guard that makes failures loud does not get to have
201
+ * branches no test can reach.
120
202
  */
121
203
  export function refusalIsAboutTheConfig(message, configPath) {
122
- const refused = REFUSED_PATH_PATTERN.exec(message)?.[1];
123
- if (!refused)
124
- return true;
125
- const refusedPath = refused.startsWith('file://') ? fileURLToPath(refused) : refused;
204
+ const refusedPath = refusedPathFrom(message);
205
+ if (refusedPath === null)
206
+ return false;
126
207
  return refusedPath === configPath || realPath(refusedPath) === realPath(configPath);
127
208
  }
128
209
  export async function importConfig(basePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stonyx",
3
- "version": "0.2.3-alpha.48",
3
+ "version": "0.2.3-alpha.49",
4
4
  "description": "Base stonyx framework module",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",