Skip to content

Simplify implementation using URL-based resolution#42

Merged
chriscalo merged 9 commits into
mainfrom
claude/issue-4-run-26332106313
May 24, 2026
Merged

Simplify implementation using URL-based resolution#42
chriscalo merged 9 commits into
mainfrom
claude/issue-4-run-26332106313

Conversation

@claude

@claude claude Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Closes #4

What changed

  • Removed path module imports (dirname, isAbsolute, resolve) — no longer needed
  • Added pathToFileURL to normalize plain paths → file:// URLs
  • Replaced toFilePath() with toCallerUrl(): instead of converting everything to a plain path, we now work in URL-space throughout
  • Extracted _resolveUrl() helper that collapses the previous three-case if/else if/else if block into a single ternary
  • Relative paths resolved with new URL(filePath, callerUrl) instead of path.resolve(dirname(callerPath), ...)
  • createRequire now receives a file:// URL directly (it accepts this natively)

What's preserved

  • caller package (no Error.stack regex)
  • options passthrough in file()
  • Module specifier resolution via createRequire
  • resolve() public API — still accepts a plain filesystem path as the second argument; still returns a plain string path
  • All 23 tests pass

The diff is −68/+17 lines, net −51 lines.

Replace `path` module with `new URL()` for relative resolution, unifying
the path-vs-URL split by normalizing caller refs to `file://` URLs upfront.
The `_resolveUrl` helper condenses the previous three-case block into a
single ternary; `toCallerUrl` handles both plain paths and `file://` URLs
so the public `resolve()` API stays backward-compatible with callers that
pass a plain `__filename`.

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread index.js Outdated
Comment thread index.js Outdated
Comment thread index.js
Comment thread index.js
- Add JSDoc to all four functions (toCallerUrl, resolveUrl, file, resolve)
- Rename _resolveUrl to resolveUrl (remove leading underscore)
- Compute isAbsolute, isRelative, isModule before the if/else structure
- Restore the three-cases comment in the appropriate place

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all four review points — all 23 tests still pass:

  1. Underscore removed_resolveUrlresolveUrl
  2. Pre-computed variablesresolveUrl now declares isAbsolute, isRelative, and isModule before the if/else if/else if chain, matching the original structure
  3. Three-cases comment restored — placed at the top of the conditional block in resolveUrl
  4. JSDoc on all functionstoCallerUrl, resolveUrl, file, and resolve all have JSDoc now

Comment thread index.js Outdated
Comment thread index.js Outdated
// 1. absolute path (starts with /) => use as is
// 2. relative path (starts with ./ or ../) => use URL resolution
// 3. module path => use require.resolve() for node_modules lookup
if (isAbsolute) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this handle all cases? Is this possible for values to bypass all three if blocks?

…ith else

- `callerRef` was vague; `path` is clear — the parameter is a filesystem
  path or file:// URL, matching how callerPath is named elsewhere
- `else if (isModule)` implied a possible gap; since isModule is
  defined as `!isAbsolute && !isRelative`, the three branches are
  exhaustive — `else` makes that obvious

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both new comments — all 23 tests still pass:

  1. "Why 'ref'?" — renamed callerRefpath. It's the clearest name for a parameter that's either a filesystem path or a file:// URL string, and matches the callerPath naming used in resolve().

  2. "Does this handle all cases?" — yes: isModule is defined as !isAbsolute && !isRelative, so the three branches are mutually exclusive and exhaustive. Changed the last else if (isModule) to else to make that obvious at a glance (no implicit undefined return possible).

Comment thread index.js
Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Done — moved exported functions before the helpers, relying on hoisting. Structure is now:

  1. export function file(...) — public API
  2. export default file
  3. export function resolve(...) — public API
  4. function toCallerUrl(...) — helper, defined after first use
  5. function resolveUrl(...) — helper, defined after first use

All 23 tests still pass.

Comment thread index.js
Comment thread index.js Outdated
if (callerPath === undefined) {
callerPath = toFilePath(caller());
}
const callerUrl = toCallerUrl(callerPath !== undefined ? callerPath : caller());

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please handle the ternary in its own assignment statement

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — split into const ref = callerPath !== undefined ? callerPath : caller(); on its own line, then const callerUrl = toCallerUrl(ref);.

Comment thread index.js
- Changed `export function file(...)` to `function file(...)` with
  explicit `export { file }` so the named export is unambiguous
- Split inline ternary in `resolve()` into its own `ref` variable
  before passing to `toCallerUrl()`

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chriscalo

Copy link
Copy Markdown
Owner

Please assess after this change: What's cleaner? What's simpler? Where is the code easier to read? Where is it more resilient and less error prone? And where is it worse in any of these regards?

@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Honest assessment of the full diff (branch vs. main):

Cleaner / simpler / easier to read

Fewer imports — dropping dirname, isAbsolute, pathResolve from node:path is a real gain. Three renamed imports gone, one URL helper in.

toCallerUrl vs toFilePath — the original used a silent try/catch to tell "is this a file:// URL or already a path?" That's a code smell: errors as control flow, and any unexpected throw would silently fall through. The replacement .startsWith("file://") is explicit, cheap, and impossible to misread.

new URL(filePath, callerUrl) vs pathResolve(dirname(callerPath), pathString) — one call vs. three nested calls. URL-native relative resolution is also the right primitive for ESM contexts.

resolveUrl extracted as a shared helper — in main, file() implements its behavior by calling the public resolve() API. That means a test breaking resolve() could silently affect file() differently than intended. Now both call the private resolveUrl() directly — cleaner separation.

Exhaustive else — the original else if (isModule) implied a possible gap (implicit undefined return). Since isModule ≡ !isAbsolute && !isRelative, the branches are always exhaustive. The else makes that structurally obvious instead of requiring a reader to work it out.

Worse / more fragile

Mixed return type from resolveUrl — returns a string for absolute paths and module paths, but a URL object for relative paths. That's the main wart. file() passes it straight to readFileSync (which accepts both, so it works), and resolve() has to do instanceof URL ? fileURLToPath(resolved) : resolved to normalize. The inconsistency creates two implicit contracts instead of one clear one. The fix would be to always return a URL or always return a string from resolveUrl.

startsWith("/") breaks on Windows — the original used isAbsolute() from node:path, which correctly handles C:\... and \\server\share prefixes. startsWith("/") only catches POSIX absolute paths. On Windows, a bare drive-letter path like C:\foo.txt would fall through to the module resolver and likely throw a confusing error. If Windows support matters, this is a regression.

Neutral

  • export { file } over export function file(...) — a minor style preference; neither is more correct.
  • The ref intermediate variable in resolve() — avoids mutating a parameter, which is fine, but the original was readable too.

Summary

The URL-space approach is genuinely cleaner overall — fewer imports, no try/catch control flow, simpler relative resolution. The main thing worth fixing before merging: unify resolveUrl's return type (always URL, or always string) to eliminate the instanceof check and the implicit dual-contract. The Windows startsWith("/") regression is worth a note too, even if this project only targets Linux/macOS.

Comment thread index.js Outdated
} catch {
return callerUrl;
return new URL(filePath, callerUrl);
} else {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this back to else if (isModule). Otherwise the computed constant is unused and the code becomes less clear in signaling intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the code already has else if (isModule) since the commit at 08:18. I had addressed it in the issue comment but forgot to reply here in the thread. Apologies for the noise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Done — changed back to else if (isModule). All 23 tests still pass.

@chriscalo

Copy link
Copy Markdown
Owner

Mixed return type from resolveUrl — returns a string for absolute paths and module paths, but a URL object for relative paths. That's the main wart. file() passes it straight to readFileSync (which accepts both, so it works), and resolve() has to do instanceof URL ? fileURLToPath(resolved) : resolved to normalize. The inconsistency creates two implicit contracts instead of one clear one. The fix would be to always return a URL or always return a string from resolveUrl.

Can we fix this? Is it better to translate at the boundary of our module?

  1. Immediately convert all string path inputs to URL objects
  2. Exclusively work in URLs inside our module
  3. Convert back to string file paths only when passing values back to the user OR when passing values to other modules

And this also makes me wonder whether much of the value of the URL type could be enjoyed in a similarly-designed Path type 🤔

- toCallerUrl() now returns a URL object instead of a string
- resolveUrl() always returns a URL: absolute/relative paths → file://,
  npm packages → file://, Node built-ins → node:<name>
- resolve() converts file: URLs via fileURLToPath, node: URLs via pathname
- file() passes URL directly to readFileSync (natively supported)
- Eliminates the instanceof URL branch and the mixed string|URL return type

Closes #42

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@chriscalo chriscalo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Responded to comments. Not sure if this is necessary to create a review.

@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Done — all 23 tests still pass. Here's what changed:

resolveUrl now always returns a URL — no more string | URL mixed type:

  • Absolute paths → pathToFileURL(filePath)file:// URL
  • Relative paths → new URL(filePath, callerUrl)file:// URL
  • npm packages → pathToFileURL(createRequire(callerUrl).resolve(filePath))file:// URL
  • Node built-ins → new URL('node:path')node: URL

toCallerUrl now returns a URL object instead of a string, so resolveUrl receives a proper URL as its base for new URL(filePath, callerUrl).

resolve() is now a single dispatchurl.protocol === "node:" ? url.pathname : fileURLToPath(url) — no instanceof check.

file() is unchangedreadFileSync accepts URL natively, so the file:// URL passes through directly.

The node: URL scheme turns out to be the right home for built-ins in URL-space: new URL('node:path').pathname is 'path', so the round-trip back to the bare module name is clean.


On the Path type musing — the analogy holds well: a typed Path could enforce the same invariant (always an absolute path internally, string at the boundary) and eliminate the class of bugs where a relative or module-specifier string leaks into a context that assumes a filesystem path. Whether that's worth a new abstraction depends on how often the caller needs to manipulate the path (join, dirname, etc.) vs. just pass it around.

Revert the named-export style from `function file() {}` + `export { file }`
back to the simpler `export function file()` declaration, which already
provides a named export without the extra statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@chriscalo chriscalo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One comment you missed

Comment thread index.js Outdated
});
const callerUrl = toCallerUrl(caller());
const resolved = resolveUrl(filePath, callerUrl);
return readFileSync(resolved, { encoding: "utf-8", ...options });

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle the options resolution in a separate constant assignment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Done — extracted readOptions into its own constant. All 23 tests still pass.

@chriscalo chriscalo merged commit 8b5b177 into main May 24, 2026
@chriscalo chriscalo deleted the claude/issue-4-run-26332106313 branch May 24, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify implementation?

1 participant