Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Core built-ins include `Math`, `JSON`, `Object`, `Function`, `Array`, `Boolean`,

Non-standard data-format APIs and SemVer are import-only Goccia runtime modules, not auto-installed globals: `goccia:csv`, `goccia:json5`, `goccia:jsonl`, `goccia:toml`, `goccia:tsv`, `goccia:yaml`, and `goccia:semver`. They expose named exports only; use `import * as CSV from "goccia:csv"` when you want the namespace-object shape. There is no default export.

`node:async_hooks` is an import-only module too, at Node's own address and with Node's exports: `AsyncLocalStorage` and `AsyncResource`, named and on the default export. The engine propagates the async context, so a store bound with `run` survives `await` and every promise-reaction continuation. See the [Async Context reference](docs/built-ins-async-context.md) and [ADR 0112](docs/adr/0112-native-async-local-storage.md).
`node:async_hooks` is an import-only module too, at Node's own address. It exports `AsyncLocalStorage` and `AsyncResource`, named and on the default export; the `async_hooks` observer API (`createHook`, `executionAsyncId`, and the rest) is out of scope. The engine propagates the async context, so a store bound with `run` survives `await` and every promise-reaction continuation. See the [Async Context reference](docs/built-ins-async-context.md) and [ADR 0112](docs/adr/0112-native-async-local-storage.md).

Native FFI is an explicit unsafe runtime opt-in (`--unsafe-ffi` or the matching configuration key). It provides native-layout structures, unions, fixed-length arrays, callbacks, and guarded library lifetimes through GocciaScript's custom bidirectional ABI machinery. See the [FFI reference](docs/built-ins-ffi.md) and [ADR 0095](docs/adr/0095-custom-bidirectional-ffi-abi-engine.md).

Expand Down
10 changes: 6 additions & 4 deletions docs/built-ins-async-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ const handle = async (request) =>
| `AsyncLocalStorage.bind(fn)` | Returns `fn` pinned to the context current at the `bind` call. Throws `TypeError` at the `bind` call if `fn` is not callable. |
| `AsyncLocalStorage.snapshot()` | Returns `(fn, ...args) => fn(...args)`, run under the context current at the `snapshot` call. It has no callback to validate, so a non-callable is a `TypeError` at the runner's call instead. |

Every function these return is named `bound` and reports its target's `length`,
as Node's do.
Every function these return is named `bound`, as Node's are. A `bind` wrapper
reports its target's `length`; a `snapshot` runner has no target and reports
`1`, the arity of the `(fn, ...args)` runner itself.

## AsyncResource

Expand Down Expand Up @@ -100,7 +101,8 @@ snapshot mechanism behind the propagation.
## Availability

`node:async_hooks` is installed by the loader runtime profile, so it resolves in
`GocciaScriptLoader`, `GocciaTestRunner`, `GocciaREPL`, and
`GocciaBenchmarkRunner` without a flag. It grants no capability — no I/O, no
`GocciaScriptLoader`, `GocciaTestRunner`, `GocciaREPL`,
`GocciaBenchmarkRunner`, and `GocciaSandboxRunner` (which applies that profile
before installing its own sandbox extension) without a flag. It grants no capability — no I/O, no
clock, no ambient authority — so nothing about it is gated. `GocciaScriptLoaderBare`
attaches no runtime and therefore does not resolve it.
3 changes: 2 additions & 1 deletion docs/differential-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ A differential suite that is handed to an external runtime uses only the
suite named `*.goccia.test.js` is the exception, because it deliberately
reaches for goccia-only globals — or asserts behavior that deliberately
diverges from both external runtimes — and it is classified `skip` for both. A suite that needs the mocking API instead imports `vi` from
`vitest`, which every runtime that can run it resolves for itself. A `.test.ts`
`vitest`, which each runtime that can run it resolves its own way — Vitest to
itself, goccia to its bundled compatibility shim. A `.test.ts`
suite works under bun because bun transpiles TypeScript natively while goccia
parses annotations as types-as-comments.

Expand Down
13 changes: 12 additions & 1 deletion docs/module-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ A resolved file inside a package is classified before it is loaded:
- A package whose manifest declares `"type": "module"` ships ES modules.
- Otherwise the source text decides: a file carrying CommonJS markers
(a `require(...)` call, `module.exports`, `exports.x`) and no ES module
markers (a statement-position `import` or `export`) is CommonJS.
markers (an `import` or `export` keyword followed by whitespace, `{`, `*`,
or a quote) is CommonJS.

The source scan is a heuristic, and it is asymmetric on purpose. A file with
both shapes — an interop shim calling `require` from an ES module — is read as
Expand All @@ -203,6 +204,16 @@ neither is inert and loads either way. Reading the file text rather than
trusting `"type"` is what makes the `module`-field deviation above work at all,
since those ES module builds routinely sit in packages that declare no type.

The scan matches raw text and does not tokenize, so it does not skip comments
or string literals: a CommonJS bundle whose banner comment mentions `import` or
`export` carries an ES module marker as far as the classifier is concerned and
is not refused here. Such a file is loaded and then fails at its first
`require`, with an `Undefined variable: require` reference error rather than the
package-relative CommonJS message below. That is the deliberate direction of
the asymmetry — a false *negative* costs a worse diagnostic, while tokenizing
every candidate file to remove it would cost a parse of every resolved package
entry.

A file classified as CommonJS raises:

```text
Expand Down
2 changes: 1 addition & 1 deletion scripts/differential/m-nodemods.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Differential suite L — bare-specifier resolution against node_modules.
// Differential suite M — bare-specifier resolution against node_modules.
//
// The fixture packages are committed under ./mods/nodemods/node_modules, so
// bun resolves them natively and goccia resolves them under the
Expand Down
2 changes: 1 addition & 1 deletion scripts/differential/n-nodemods.goccia.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Differential suite M — the node_modules behaviours that are goccia's own.
// Differential suite N — the node_modules behaviours that are goccia's own.
//
// Both are deliberate deviations recorded in docs/module-resolution.md, so
// neither bun nor vitest can act as an oracle for them and this file is
Expand Down
67 changes: 28 additions & 39 deletions scripts/differential/p-callintrinsics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ describe("user-defined call/apply/bind on function objects", () => {
});

test("own properties on a plain function run instead of the intrinsics", () => {
const host = function () {
return "host";
};
const host = () => "host";
host.call = (...args) => `own call(${args.join(",")})`;
host.apply = (thisArg, list) => `own apply(${thisArg},[${list.join(",")}])`;
host.bind = (...args) => `own bind(${args.join(",")})`;
Expand All @@ -55,22 +53,23 @@ describe("user-defined call/apply/bind on function objects", () => {
return `inherited apply(${tag},${list.length})`;
},
};
const host = function () {
return "host";
};
const host = () => "host";
Object.setPrototypeOf(host, behaviour);

expect(host.call("t")).toBe("inherited call(t)");
expect(host.apply("t", [1, 2, 3])).toBe("inherited apply(t,3)");
});

test("a different built-in installed as `apply` keeps its own semantics", () => {
const inner = function () {
return `inner(thisIsArray=${Array.isArray(this)})`;
};
const host = function () {
return "host";
};
// Extracted from an object literal rather than written with `function`:
// the shorthand method is a real function with a dynamic `this`, which is
// what the receiver assertion below needs.
const inner = ({
m() {
return `inner(thisIsArray=${Array.isArray(this)})`;
},
}).m;
const host = () => "host";
host.apply = Reflect.apply;

// Reflect.apply(target, thisArgument, argumentsList): the receiver `host` is
Expand All @@ -82,9 +81,7 @@ describe("user-defined call/apply/bind on function objects", () => {
});

test("arity variants of a user-defined call are all forwarded", () => {
const host = function () {
return "host";
};
const host = () => "host";
host.call = (...args) => args.length;

expect(host.call()).toBe(0);
Expand Down Expand Up @@ -127,9 +124,11 @@ describe("user-defined call/apply/bind on function objects", () => {
});

test("shadowing an intrinsic does not disturb the intrinsic itself", () => {
const host = function (a, b) {
return `${this.tag}:${a}:${b}`;
};
const host = ({
m(a, b) {
return `${this.tag}:${a}:${b}`;
},
}).m;
host.call = () => "shadowed";
host.apply = () => "shadowed";

Expand All @@ -140,9 +139,11 @@ describe("user-defined call/apply/bind on function objects", () => {
});

describe("the Function.prototype intrinsics themselves", () => {
const collect = function (...args) {
return `${this === undefined ? "undefined" : this.tag}:${args.join(",")}`;
};
const collect = ({
m(...args) {
return `${this === undefined ? "undefined" : this.tag}:${args.join(",")}`;
},
}).m;

test("call forwards the this value and every argument", () => {
const receiver = { tag: "r" };
Expand Down Expand Up @@ -213,9 +214,7 @@ describe("the Function.prototype intrinsics themselves", () => {
// never reach the callee as a distinguishable value, in any argument count and
// through any of the entry points that build the list.
test("apply turns argument-array holes into undefined", () => {
const args = function (...rest) {
return rest.map((value) => String(value)).join("|");
};
const args = (...rest) => rest.map((value) => String(value)).join("|");

expect(args.apply(undefined, [1, , 3])).toBe("1|undefined|3");
expect(args.apply(undefined, [, 2, 3])).toBe("undefined|2|3");
Expand All @@ -229,9 +228,7 @@ describe("the Function.prototype intrinsics themselves", () => {
});

test("holes stay undefined through bound functions and detached apply", () => {
const args = function (...rest) {
return rest.map((value) => String(value)).join("|");
};
const args = (...rest) => rest.map((value) => String(value)).join("|");
const apply = Function.prototype.apply;

expect(args.bind(undefined).apply(undefined, [1, , 3])).toBe("1|undefined|3");
Expand All @@ -243,9 +240,7 @@ describe("the Function.prototype intrinsics themselves", () => {
});

test("argument-array holes are read through the prototype chain", () => {
const args = function (...rest) {
return rest.map((value) => String(value)).join("|");
};
const args = (...rest) => rest.map((value) => String(value)).join("|");
let reads = 0;

Object.defineProperty(Array.prototype, 1, {
Expand Down Expand Up @@ -274,9 +269,7 @@ describe("the Function.prototype intrinsics themselves", () => {
// one an engine loses the moment it reads the arguments in whatever order its
// call sequence happens to evaluate.
test("inherited index getters fire in ascending index order", () => {
const args = function (...rest) {
return rest.map((value) => String(value)).join("|");
};
const args = (...rest) => rest.map((value) => String(value)).join("|");
let order = "";
const define = (index) =>
Object.defineProperty(Array.prototype, index, {
Expand Down Expand Up @@ -318,9 +311,7 @@ describe("the Function.prototype intrinsics themselves", () => {
});

test("a getter that truncates the argument array keeps the original count", () => {
const args = function (...rest) {
return rest.map((value) => String(value)).join("|");
};
const args = (...rest) => rest.map((value) => String(value)).join("|");
let reading = null;

Object.defineProperty(Array.prototype, 1, {
Expand Down Expand Up @@ -354,9 +345,7 @@ describe("the Function.prototype intrinsics themselves", () => {
});

test("apply uses the array's length, not its dense element count", () => {
const args = function (...rest) {
return rest.map((value) => String(value)).join("|");
};
const args = (...rest) => rest.map((value) => String(value)).join("|");
const grown = [1, 2];
grown.length = 5;

Expand Down
12 changes: 12 additions & 0 deletions scripts/test-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3229,10 +3229,15 @@ console.log("Assertion failure text...");
"}",
"class ProtoNamed extends Error {}",
"ProtoNamed.prototype.name = 'ProtoAssigned';",
// An explicit prototype name that happens to spell "Error" is still
// the author's answer, so the constructor name must not displace it.
"class ProtoErrorNamed extends Error {}",
"ProtoErrorNamed.prototype.name = 'Error';",
'test("plain error", () => Promise.reject(new Error("boom")));',
'test("subclass error", () => Promise.reject(new MyErr("boom")));',
'test("named subclass error", () => Promise.reject(new NamedErr("boom")));',
'test("prototype-named subclass error", () => Promise.reject(new ProtoNamed("boom")));',
'test("prototype-named Error subclass", () => Promise.reject(new ProtoErrorNamed("boom")));',
'test("native error", () => Promise.reject(new TypeError("bad")));',
'test("plain object", () => Promise.reject({ code: 42 }));',
'test("message only", () => Promise.reject({ message: "hi" }));',
Expand All @@ -3256,6 +3261,13 @@ console.log("Assertion failure text...");
`TestRunner (${mode}) should report "${expected}", got: ${rejectionOut}`,
);
}
// ProtoErrorNamed spells its prototype name "Error" on purpose, which
// reads identically to the inherited default; only the absence of the
// constructor name tells the two apart.
if (rejectionOut.includes("ProtoErrorNamed: boom"))
throw new Error(
`TestRunner (${mode}) must keep an explicitly assigned "Error" prototype name, got: ${rejectionOut}`,
);
}

// Every member the shim does not implement must keep throwing by name. The
Expand Down
39 changes: 39 additions & 0 deletions source/shared/FileUtils.Test.pas
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ TFileUtilsTests = class(TTestSuite)
procedure TestMultipleFilesInMultipleSubdirs;
procedure TestNoMatchingFilesAmongMany;
procedure TestMixedExtensionsAcrossDepths;
procedure TestIsAbsoluteHostPathRootedForms;
procedure TestIsAbsoluteHostPathRelativeForms;
public
procedure SetupTests; override;
procedure BeforeEach; override;
Expand All @@ -52,6 +54,10 @@ procedure TFileUtilsTests.SetupTests;
Test('Multiple files across multiple subdirectories', TestMultipleFilesInMultipleSubdirs);
Test('No matching files among many non-matching returns empty', TestNoMatchingFilesAmongMany);
Test('Mixed extensions across various depths', TestMixedExtensionsAcrossDepths);
Test('IsAbsoluteHostPath accepts the platform''s rooted spellings',
TestIsAbsoluteHostPathRootedForms);
Test('IsAbsoluteHostPath rejects paths read against a working directory',
TestIsAbsoluteHostPathRelativeForms);
end;

procedure TFileUtilsTests.BeforeEach;
Expand Down Expand Up @@ -341,6 +347,39 @@ procedure TFileUtilsTests.TestMixedExtensionsAcrossDepths;
end;
end;

{ A ceiling directory that is classified absolute is used verbatim; one that is
not is anchored to the directory the setting came from
(Goccia.Modules.Configuration AnchorCeilingDirectory), so misclassifying a
drive-relative or backslash-prefixed path silently moves the capability
boundary. The spellings are platform-specific, so the expectations are too. }
procedure TFileUtilsTests.TestIsAbsoluteHostPathRootedForms;
begin
{ A leading '/' roots a path on both platforms. }
Expect<Boolean>(IsAbsoluteHostPath('/usr/local/lib')).ToBe(True);
{$IFNDEF UNIX}
Expect<Boolean>(IsAbsoluteHostPath('C:\packages')).ToBe(True);
Expect<Boolean>(IsAbsoluteHostPath('c:/packages')).ToBe(True);
Expect<Boolean>(IsAbsoluteHostPath('\\server\share\pkg')).ToBe(True);
Expect<Boolean>(IsAbsoluteHostPath('\packages')).ToBe(True);
{$ENDIF}
end;

procedure TFileUtilsTests.TestIsAbsoluteHostPathRelativeForms;
begin
Expect<Boolean>(IsAbsoluteHostPath('')).ToBe(False);
Expect<Boolean>(IsAbsoluteHostPath('packages')).ToBe(False);
Expect<Boolean>(IsAbsoluteHostPath('./packages')).ToBe(False);
Expect<Boolean>(IsAbsoluteHostPath('../packages')).ToBe(False);
{ Drive-relative: resolved against C:'s own working directory, not the root. }
Expect<Boolean>(IsAbsoluteHostPath('C:packages')).ToBe(False);
Expect<Boolean>(IsAbsoluteHostPath('C:')).ToBe(False);
{$IFDEF UNIX}
{ A backslash is an ordinary filename character on UNIX. }
Expect<Boolean>(IsAbsoluteHostPath('\packages')).ToBe(False);
Expect<Boolean>(IsAbsoluteHostPath('C:\packages')).ToBe(False);
{$ENDIF}
end;

begin
Randomize;
TestRunnerProgram.AddSuite(TFileUtilsTests.Create('FileUtils'));
Expand Down
30 changes: 26 additions & 4 deletions source/shared/FileUtils.pas
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ function FindAllFilesExcludingDirectories(const ADirectory: string;
const AFileExtensions: array of string;
const AExcludedDirectoryNames: array of string): TStringList;
{ True when APath is rooted rather than interpreted against a working
directory: a leading path separator, a drive letter, or a UNC prefix.
directory. The test is platform-specific because the spellings are: on UNIX
only a leading '/' roots a path, and a backslash is an ordinary filename
character; on Windows a UNC prefix, a leading separator, or a drive letter
*followed by a separator* does, while the drive-relative `C:packages` is
resolved against that drive's working directory and is therefore not
absolute.
(Several units still carry private copies of this predating the shared one;
they are unchanged here rather than refactored in passing.) }
function IsAbsoluteHostPath(const APath: string): Boolean;
Expand All @@ -46,15 +51,32 @@ implementation
TextEncoding;

function IsAbsoluteHostPath(const APath: string): Boolean;
{$IFDEF UNIX}
begin
{ A backslash is an ordinary filename character here, so `\packages` is a
relative path, not a rooted one. }
Result := (Length(APath) > 0) and (APath[1] = '/');
end;
{$ELSE}
begin
if Length(APath) = 0 then
Exit(False);
if (APath[1] = '/') or (APath[1] = '\') then
{ A UNC path is rooted at the share. }
if (Copy(APath, 1, 2) = '\\') or (Copy(APath, 1, 2) = '//') then
Exit(True);
if (Length(APath) >= 2) and (APath[2] = ':') then
{ A leading separator with no drive is root-relative rather than fully
qualified, but it is still rooted: it is not interpreted against the
working directory. }
if (APath[1] = '\') or (APath[1] = '/') then
Exit(True);
Result := Copy(APath, 1, 2) = '\\';
{ `C:\x` is rooted; `C:x` is drive-*relative* — resolved against that
drive's own working directory — so only the separator form counts. }
Result := (Length(APath) >= 3) and
(APath[2] = ':') and
((APath[3] = '\') or (APath[3] = '/')) and
(UpCase(APath[1]) >= 'A') and (UpCase(APath[1]) <= 'Z');
end;
{$ENDIF}

function ExpandHostFileName(const APath: string): string;
begin
Expand Down
Loading
Loading