diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9a83d..1c3d708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. 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 @@ -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. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 59314ce..5fd9214 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -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 ` in the file. (`--lint` + only for now; the in-file directives above also cover the LSP.) ## Name resolution (`E003`) diff --git a/docs/PACKAGE_SPEC.md b/docs/PACKAGE_SPEC.md index 9004cdb..cd5e139 100644 --- a/docs/PACKAGE_SPEC.md +++ b/docs/PACKAGE_SPEC.md @@ -23,7 +23,7 @@ or corrupt `eigs.json` can never break `import`. | File | Role | |------|------| -| `eigs.json` | Manifest: `name`, `version`, and `deps` keyed by `/`. | +| `eigs.json` | Manifest: `name`, `version`, and `deps` keyed by `/`. 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//` | One directory per dep — a git checkout at the locked commit. | diff --git a/docs/TRACE.md b/docs/TRACE.md index 1740a19..aebe039 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -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) diff --git a/src/builtins.c b/src/builtins.c index 65c5662..7530845 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -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 ---- */ diff --git a/src/lint.c b/src/lint.c index 062161d..bcf233c 100644 --- a/src/lint.c +++ b/src/lint.c @@ -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": { "": ["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 ` 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 ("/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; @@ -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 → diff --git a/tests/test_lint.sh b/tests/test_lint.sh index 83eba88..bc43d5c 100644 --- a/tests/test_lint.sh +++ b/tests/test_lint.sh @@ -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 []' — 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 diff --git a/tests/test_replay.sh b/tests/test_replay.sh index 6fa9429..35d92c4 100755 --- a/tests/test_replay.sh +++ b/tests/test_replay.sh @@ -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