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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to EigenScript are documented here.
## [Unreleased]

### Added
- **Per-file lint allow-list in `eigs.json` (#455).** A project can now silence
a lint code for a whole file without editing it — an `eigs.json` `lint.allow`
map from project-root-relative path to a list of codes (`"all"` silences
every code): `{"lint":{"allow":{"lib/generated.eigs":["W003","W017"]}}}`. The
escape for generated or vendored modules that shouldn't carry inline
`# lint: allow` comments. Resolution walks from the linted file up to the
project root (the dir containing `eigs.json`, reusing the module resolver's
walk), so it applies regardless of the cwd. A code allowed here filters
exactly like a `# lint: allow-file <code>`. Closes the last residual of #399.
(`--lint`; the in-file directives still cover the LSP.)
- **`lib/sync` — cooperative-task locks (#488).** A `lib/sync.eigs` stdlib
module giving mutual exclusion **across** `task_yield` points for the #408
task layer: `lock_new` / `lock_acquire` / `lock_release`, plus
Expand Down Expand Up @@ -136,6 +146,18 @@ All notable changes to EigenScript are documented here.
(the one legitimate empty-expression position) still yields `null` (#494).

### Fixed
- **`args` now rides the trace tape (#471).** The `args` builtin returned
`argv` directly, unwrapped — the last un-taped nondeterminism source
reachable by a pure script, and a hole in the closed-world invariant behind
deterministic replay. A program that branched on `args` recorded a tape
under one invocation and silently diverged when replayed under a different
argv (the exact class chaos-mode `--seed N` scheduling for the #408 task
layer would hit). The recorded argument list now rides the tape as one `N`
record and replay serves it regardless of the live argv. Because `args`
*builds* its list before returning, it uses the `TRACE_NONDET_TAKE` /
`TRACE_NONDET_RECORD` pair (not `TRACE_NONDET_RET`), so under replay the
live list is neither built nor leaked. Regression: `tests/test_replay.sh`
(record under `A B`, replay under `X Y Z`, recorded args win).
- **Circular `import` / `load_file` no longer crashes (#496).** A mutual or
self-referential `import` (a→b→a) or `load_file` used to recurse through
`vm_execute` until the C stack overflowed — SIGSEGV, `rc=139`, uncatchable.
Expand Down
22 changes: 21 additions & 1 deletion docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,27 @@ nothing:
resolve, read, or parse disables the pass for the fragment (never a false
positive).

(Per-file allow-lists in `eigs.json` are not yet wired up — see #455.)
- **Per-file allow-list in `eigs.json`** (#455) silences codes for a whole
file **without editing the file** — the escape for generated or vendored
modules a project should not sprinkle `# lint: allow` comments through:

```json
{
"lint": {
"allow": {
"lib/generated_tables.eigs": ["W003", "W015"],
"vendor/thirdparty.eigs": ["all"]
}
}
}
```

Keys are paths **relative to the project root** (the directory containing
`eigs.json`); `all` silences every code. Resolution walks up from the
linted file to that root — the same walk the module resolver uses — so it
applies regardless of the cwd the linter runs from. A code allowed here is
filtered exactly like a `# lint: allow-file <code>` in the file. (`--lint`
only for now; the in-file directives above also cover the LSP.)

## Name resolution (`E003`)

Expand Down
2 changes: 1 addition & 1 deletion docs/PACKAGE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ or corrupt `eigs.json` can never break `import`.

| File | Role |
|------|------|
| `eigs.json` | Manifest: `name`, `version`, and `deps` keyed by `<owner>/<name>`. |
| `eigs.json` | Manifest: `name`, `version`, and `deps` keyed by `<owner>/<name>`. Also an optional `lint.allow` map (per-file lint allow-list — see docs/DIAGNOSTICS.md). |
| `eigs.lock.json` | Resolved commit SHAs + content hashes for each dep. |
| `eigs_modules/<leaf>/` | One directory per dep — a git checkout at the locked commit. |

Expand Down
9 changes: 8 additions & 1 deletion docs/TRACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,19 @@ perspective lands on the tape as an `N` record:
- **Time:** `monotonic_ns`, `monotonic_ms`
- **Environment / files:** `env_get`, `read_text`, `read_bytes`,
`read_bytes_buf`
- **Process:** `args` (command-line arguments — differ across
invocations, so the recorded list is served on replay regardless of
the live argv; #471)
- **HTTP extension:** `http_post` (success and all error paths),
`http_request_body`, `http_session_id`, `http_request_headers`

The hook is the `TRACE_NONDET_RET` macro in `src/trace.h`, used at
every nondet return site — adding a new nondet builtin means wrapping
its return in the same macro.
its return in the same macro. A builtin that *builds* its return value
(a list, buffer, or dict) before returning uses the `TRACE_NONDET_TAKE`
/ `TRACE_NONDET_RECORD` pair instead (`args` does): the early `TAKE`
short-circuits under replay before the value is built, so the live
construction is neither run nor leaked.

## Non-Replayable Builtins (issue #148)

Expand Down
15 changes: 13 additions & 2 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -1915,15 +1915,26 @@ void eigenscript_set_args(int argc, char **argv) {
g_argv = argv;
}

/* args of null → list of command-line arguments (after the script name) */
/* args of null → list of command-line arguments (after the script name).
*
* argv is a nondeterminism source across invocations (the closed-world
* replay invariant, #471): a program that branches on args records under
* one argv and would silently diverge when replayed under another. So the
* built list rides the tape as one N record and replay serves the recorded
* list regardless of the live argv. The construction happens before the
* return value exists, so this uses the TAKE/RECORD pair rather than
* TRACE_NONDET_RET — under replay the early TAKE short-circuits before the
* list is built, avoiding both the wasted work and the abandoned (leaked)
* live list. */
Value* builtin_args(Value *arg) {
(void)arg;
TRACE_NONDET_TAKE("args");
Value *list = make_list(g_argc > 2 ? g_argc - 2 : 0);
/* g_argv[0] = eigenscript, g_argv[1] = script.eigs, g_argv[2..] = user args */
for (int i = 2; i < g_argc; i++) {
list_append_owned(list, make_str(g_argv[i]));
}
return list;
TRACE_NONDET_RECORD("args", list);
}

/* ---- Path manipulation ---- */
Expand Down
88 changes: 86 additions & 2 deletions src/lint.c
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,86 @@ int lint_file_allows(const char *source, const char *code) {
return 0;
}

#if !EIGENSCRIPT_FREESTANDING
/* #455: per-file lint allow-list in eigs.json (residual of #399). Walk up from
* the linted file's directory to the project root (the dir containing
* eigs.json — the same root the module resolver's walk stops at), read its
* { "lint": { "allow": { "<root-relative path>": ["W003", ...] } } }
* map, and return the newly-owned VAL_LIST of codes allowed for `path`
* file-wide (or NULL). Caller val_decrefs. A code listed here is filtered
* exactly like a `# lint: allow-file <code>` in the file — the escape for
* generated/vendored files a project can't sprinkle comments into. */
static Value *eigs_json_lint_allow_for(const char *path) {
char real[4096];
if (!realpath(path, real)) return NULL;

/* Walk dirname(real) upward for eigs.json; the containing dir is root. */
char dir[4096];
snprintf(dir, sizeof(dir), "%s", real);
char *slash = strrchr(dir, '/');
if (!slash) return NULL;
*slash = '\0';

char root[4096] = {0};
char jsonpath[4200] = {0};
for (int i = 0; i < 64; i++) {
char probe[4200];
snprintf(probe, sizeof(probe), "%.4000s/eigs.json", dir);
if (access(probe, F_OK) == 0) {
snprintf(root, sizeof(root), "%s", dir);
snprintf(jsonpath, sizeof(jsonpath), "%s", probe);
break;
}
char *s = strrchr(dir, '/');
if (!s || s == dir) return NULL;
*s = '\0';
}
if (!root[0]) return NULL;

long sz = 0;
char *js = read_file_util(jsonpath, &sz);
if (!js) return NULL;
int pos = 0;
Value *j = eigs_json_parse_value(js, &pos);
free(js);
if (!j) return NULL;

Value *result = NULL;
if (j->type == VAL_DICT) {
Value *lint = dict_get(j, "lint");
if (lint && lint->type == VAL_DICT) {
Value *allow = dict_get(lint, "allow");
if (allow && allow->type == VAL_DICT) {
/* real, relative to root ("<root>/lib/x.eigs" → "lib/x.eigs"). */
size_t rl = strlen(root);
const char *rel = real;
if (strncmp(real, root, rl) == 0 && real[rl] == '/')
rel = real + rl + 1;
Value *codes = dict_get(allow, rel);
if (codes && codes->type == VAL_LIST) {
val_incref(codes);
result = codes;
}
}
}
}
val_decref(j);
return result;
}

/* Is `code` (or "all") in the eigs.json allow-list `codes` (may be NULL)? */
static int eigs_json_allows(Value *codes, const char *code) {
if (!codes || codes->type != VAL_LIST) return 0;
for (int i = 0; i < codes->data.list.count; i++) {
Value *c = codes->data.list.items[i];
if (c && c->type == VAL_STR &&
(strcmp(c->data.str, code) == 0 || strcmp(c->data.str, "all") == 0))
return 1;
}
return 0;
}
#endif /* !EIGENSCRIPT_FREESTANDING */

static int lint_suppressed(const char *source, int warn_line, const char *code) {
static const char MARKER[] = "# lint: allow";
const size_t MLEN = sizeof(MARKER) - 1;
Expand Down Expand Up @@ -1855,16 +1935,20 @@ int eigenscript_lint(const char *path, int json_mode, int fail_on_warning) {
lint_run_checks(ast, path, source, &ctx);

/* #399 inline suppression: drop warnings silenced by a `# lint: allow`
* comment on their line (or the line above). Compact in place so the
* suppressed diagnostics vanish from both human and JSON output. */
* comment on their line (or the line above), a `# lint: allow-file`, or
* the #455 per-file eigs.json allow-list. Compact in place so suppressed
* diagnostics vanish from both human and JSON output. */
{
Value *ej_allow = eigs_json_lint_allow_for(path);
int kept = 0;
for (int w = 0; w < ctx.warning_count; w++) {
if (!lint_file_allows(source, ctx.warnings[w].code) &&
!eigs_json_allows(ej_allow, ctx.warnings[w].code) &&
!lint_suppressed(source, ctx.warnings[w].line, ctx.warnings[w].code))
ctx.warnings[kept++] = ctx.warnings[w];
}
ctx.warning_count = kept;
if (ej_allow) val_decref(ej_allow);
}

/* Emit. JSON goes to stdout (machine-consumable, even when clean →
Expand Down
31 changes: 31 additions & 0 deletions tests/test_lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,37 @@ OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_not_contains "E003 silent on scope-precise legal reads" "$OUTPUT" "E003"
rm -f "$TMPFILE"

# --- #455: per-file lint allow-list in eigs.json ---
# A project can suppress a code for a whole file via eigs.json, without inline
# comments (generated/vendored code). Resolution walks to the project root
# (dir with eigs.json) regardless of the cwd the linter runs from.
LINTPKG=$(mktemp -d /tmp/lint_pkg_XXXXXX)
mkdir -p "$LINTPKG/lib"
# W017: 'f of [<expr>]' — one bare-list arg.
printf 'define f(x) as:\n return x\nprint of (f of [1])\n' > "$LINTPKG/lib/gen.eigs"
cp "$LINTPKG/lib/gen.eigs" "$LINTPKG/lib/other.eigs"

printf '{ "lint": { "allow": { "lib/gen.eigs": ["W017"] } } }\n' > "$LINTPKG/eigs.json"
OUT_ALLOW=$($EIGS --lint "$LINTPKG/lib/gen.eigs" 2>&1 || true)
check_not_contains "#455 eigs.json allow suppresses listed code for the file" "$OUT_ALLOW" "W017"

OUT_OTHER=$($EIGS --lint "$LINTPKG/lib/other.eigs" 2>&1 || true)
check_contains "#455 a file not in the allow-list still fires" "$OUT_OTHER" "W017"

printf '{ "lint": { "allow": { "lib/gen.eigs": ["W003"] } } }\n' > "$LINTPKG/eigs.json"
OUT_WRONGCODE=$($EIGS --lint "$LINTPKG/lib/gen.eigs" 2>&1 || true)
check_contains "#455 allowing a different code leaves W017 firing" "$OUT_WRONGCODE" "W017"

printf '{ "lint": { "allow": { "lib/gen.eigs": ["all"] } } }\n' > "$LINTPKG/eigs.json"
OUT_ALL=$($EIGS --lint "$LINTPKG/lib/gen.eigs" 2>&1 || true)
check_not_contains "#455 'all' suppresses every code for the file" "$OUT_ALL" "W017"

# Root discovery is cwd-independent.
printf '{ "lint": { "allow": { "lib/gen.eigs": ["W017"] } } }\n' > "$LINTPKG/eigs.json"
OUT_CWD=$( (cd / && "$EIGS" --lint "$LINTPKG/lib/gen.eigs" 2>&1) || true)
check_not_contains "#455 allow-list resolves from project root, not cwd" "$OUT_CWD" "W017"
rm -rf "$LINTPKG"

echo ""
echo "Results: $PASS passed, $FAIL failed, $TOTAL total"
exit $FAIL
20 changes: 20 additions & 0 deletions tests/test_replay.sh
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,26 @@ else
fail "stale EIGS_REPLAY --version" "rc=$VRC out='$VOUT'"
fi

# ---- #471: args (argv) is a taped nondeterminism source ----
# Record the tape under one argv, then replay the SAME script under a
# DIFFERENT argv. The recorded args must win — a program that branches on
# args would otherwise silently diverge on replay (the closed-world hole).
cat > "$TMPDIR/p_args.eigs" <<'EOF'
print of (args of null)
EOF

TAPE_A="$TMPDIR/args.tape"
REC_A=$(EIGS_TRACE="$TAPE_A" "$EIGS" "$TMPDIR/p_args.eigs" A B 2>&1)
# Live (no replay) under the mutated argv — proves the tape actually overrides.
LIVE_A=$(EIGS_TRACE=/dev/null "$EIGS" "$TMPDIR/p_args.eigs" X Y Z 2>&1)
REP_A=$(EIGS_REPLAY="$TAPE_A" "$EIGS" "$TMPDIR/p_args.eigs" X Y Z 2>&1)

if [ "$REC_A" = '["A", "B"]' ] && [ "$REP_A" = '["A", "B"]' ] && [ "$LIVE_A" = '["X", "Y", "Z"]' ]; then
ok "args replay: recorded argv wins under EIGS_REPLAY with a mutated argv"
else
fail "args replay" "rec='$REC_A' live='$LIVE_A' rep='$REP_A'"
fi

echo
echo "REPLAY: $PASS passed, $FAIL failed"
[ "$FAIL" = "0" ] && exit 0 || exit 1
Loading