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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to EigenScript are documented here.

## [Unreleased]

### Added
- **`# lint: loaded-by <relpath>` fragment directive (#460)** — a library
fragment declares its composer (the entry point that `load_file`s it, or
a concat sibling in out-of-language composition); E003 collects the named
file's transitive binding set as context and lints the fragment against
it. Kills the ~380 cross-module false positives the v0.27.0 consumer
sweep hit (DMG 217→0, EigenMiniSat 99→0, verified on the real repos)
while — unlike `# lint: allow-file E003` — a genuine typo in the fragment
still fires. Repeatable, works in `--lint` and the LSP (which now passes
the live document buffer through `lint_collect`, so as-you-type edits to
the directive take effect), fails open on an unresolvable context.
Documented in docs/DIAGNOSTICS.md.

### Fixed
- **A user rebinding of `dispatch` wins over the `OP_DISPATCH`
superinstruction (#459)** — previously `define dispatch(a, b, c)` (or a
Expand Down
37 changes: 34 additions & 3 deletions docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,34 @@ nothing:
# lint: allow-file E003 -- fragment of lib/ui.eigs: loader binds _theme/_ui
```

For the `E003` fragment case specifically, prefer `# lint: loaded-by`
(below) — it keeps undefined-name protection that `allow-file E003`
abandons.

- **`# lint: loaded-by <relpath>`** (#460) declares this file a library
*fragment* of the named composer. E003 collects the named file's
**transitive binding set** (its binders, plus everything its literal
`load_file`s bind, exactly as if linting it as an entry point) and lints
this file against that context — so the composed-in names stop flagging
while **a genuine typo in the fragment still fires**. The path resolves
like a `load_file` target from the fragment's directory. The directive is
repeatable, works in both `--lint` and the LSP, and covers both
composition styles:

```eigenscript
# lint: loaded-by ../dmg.eigs (the entry point that load_files me)
```

```eigenscript
# lint: loaded-by sibling.eigs (out-of-language concat: the named
file need not load me — its binders
become context either way)
```

Fail-open like every `E003` edge: a context file the linter cannot
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.)

## Name resolution (`E003`)
Expand Down Expand Up @@ -281,9 +309,12 @@ ROADMAP's sanctioned alternative to a type system. Its model:
linter cannot resolve, read, or parse also fails open.
- **Module members are not checked.** `import m` binds `m`; `m.anything` is
a dict key, outside this pass.
- For a file that is *deliberately* a fragment of a larger program, use
`# lint: allow-file E003` (above). For a single host-injected name, a
per-line `# lint: allow E003` works as usual.
- For a file that is *deliberately* a fragment of a larger program, declare
its composer with `# lint: loaded-by <relpath>` (above) — it lints the
fragment against the composer's binding set and keeps typo protection.
`# lint: allow-file E003` remains the blunt escape (silences the pass
entirely); for a single host-injected name, a per-line
`# lint: allow E003` works as usual.

`E100` is the **category code** for an uncaught runtime error. It is
deliberately *not* injected into the `Error line N: ...` text, because
Expand Down
4 changes: 2 additions & 2 deletions src/eigenlsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ static void send_diagnostics(Document *doc) {
LintDiag diags[256];
const char *fs_path =
strncmp(doc->uri, "file://", 7) == 0 ? doc->uri + 7 : NULL;
int n = lint_collect(doc->ast, fs_path, diags, 256);
int n = lint_collect(doc->ast, fs_path, doc->text, diags, 256);
int emitted = 0;
for (int i = 0; i < n; i++) {
if (lint_file_allows(doc->text, diags[i].code)) continue;
Expand Down Expand Up @@ -1602,7 +1602,7 @@ static void handle_code_action(int id, const char *params) {
LintDiag diags[256];
const char *fs_path =
strncmp(doc->uri, "file://", 7) == 0 ? doc->uri + 7 : NULL;
int n = lint_collect(doc->ast, fs_path, diags, 256);
int n = lint_collect(doc->ast, fs_path, doc->text, diags, 256);

strbuf sb;
strbuf_init(&sb);
Expand Down
11 changes: 8 additions & 3 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -1036,11 +1036,16 @@ typedef struct {
/* Run all lint checks on an already-parsed AST; fill out[] (up to max),
* return the count. `path` = the source file's filesystem path (NULL if
* unknown); it anchors E003's literal-load_file resolution, which reads the
* loaded files — otherwise no I/O. Used by the LSP to publish diagnostics. */
int lint_collect(ASTNode *ast, const char *path, LintDiag *out, int max);
* loaded files — otherwise no I/O. `source` = the raw source text (NULL if
* unavailable); it carries the `# lint: loaded-by` fragment directive
* (#460) — the LSP passes the live doc buffer so as-you-type edits to the
* directive take effect. Used by the LSP to publish diagnostics. */
int lint_collect(ASTNode *ast, const char *path, const char *source,
LintDiag *out, int max);
/* 1 if the source carries a file-wide `# lint: allow-file <code>` directive
* for `code` (or `all`). Callers of lint_collect apply it themselves (the
* CLI and the LSP both do), since lint_collect never sees the source. */
* CLI and the LSP both do) — suppression filters lint_collect's OUTPUT;
* the loaded-by directive feeds its INPUT via the `source` param above. */
int lint_file_allows(const char *source, const char *code);
int eigenscript_lint(const char *path, int json_mode, int fail_on_warning);

Expand Down
44 changes: 37 additions & 7 deletions src/lint.c
Original file line number Diff line number Diff line change
Expand Up @@ -1470,7 +1470,7 @@ static void e003_walk(ASTNode *n, E003 *e, LintContext *ctx, int mode) {
}

static void check_undefined_names(ASTNode *ast, const char *path,
LintContext *ctx) {
const char *source, LintContext *ctx) {
E003 e;
memset(&e, 0, sizeof(e));
e.bind = env_new(NULL);
Expand Down Expand Up @@ -1506,6 +1506,34 @@ static void check_undefined_names(ASTNode *ast, const char *path,
e.base_dir[d] = '\0';
}
}
/* #460: `# lint: loaded-by <relpath>` — this file is a library FRAGMENT
* composed by the named file: a load_file loader (DMG's dmg.eigs), or a
* sibling in out-of-language composition (EigenMiniSat's ROM-bundle
* concat — the context file need not load the fragment; its binders are
* collected either way, transitively). The named file's binding set
* becomes the lint context, then THIS file is flagged against it — so
* unlike `# lint: allow-file E003`, a genuine typo in the fragment
* still fires. Path resolves like a load_file target from the
* fragment's directory. Repeatable. Fail-open like every E003 edge: an
* unresolvable or malformed context disables the pass for the file. */
if (source) {
static const char MARKER[] = "# lint: loaded-by";
const size_t MLEN = sizeof(MARKER) - 1;
for (const char *q = source; (q = strstr(q, MARKER)) != NULL; ) {
q += MLEN;
while (*q == ' ' || *q == '\t') q++;
const char *tk = q;
while (*q && *q != ' ' && *q != '\t' && *q != '\n' && *q != '\r') q++;
if (q > tk && (size_t)(q - tk) < 4096) {
char rel[4096];
memcpy(rel, tk, (size_t)(q - tk));
rel[q - tk] = '\0';
e003_load(&e, rel);
} else {
e.dynamic = 1;
}
}
}
e003_walk(ast, &e, NULL, E003_COLLECT);
if (!e.dynamic)
e003_walk(ast, &e, ctx, E003_FLAG);
Expand All @@ -1515,14 +1543,15 @@ static void check_undefined_names(ASTNode *ast, const char *path,

#endif /* !EIGENSCRIPT_FREESTANDING */

static void lint_run_checks(ASTNode *ast, const char *path, LintContext *ctx) {
static void lint_run_checks(ASTNode *ast, const char *path,
const char *source, LintContext *ctx) {
check_outer_mutation(ast, ctx);
check_bare_predicate_alias(ast, ctx);
check_one_element_arg_list(ast, ctx);
#if !EIGENSCRIPT_FREESTANDING
check_undefined_names(ast, path, ctx);
check_undefined_names(ast, path, source, ctx);
#else
(void)path;
(void)path; (void)source;
#endif
check_empty_blocks(ast, ctx);
check_dup_keys(ast, ctx);
Expand Down Expand Up @@ -1581,10 +1610,11 @@ static void lint_run_checks(ASTNode *ast, const char *path, LintContext *ctx) {
/* Public structured-lint entry: run checks on an AST, copy diagnostics out.
* `path` is the source file's filesystem path (NULL if unknown) — it anchors
* E003's literal-load_file resolution; all other checks ignore it. */
int lint_collect(ASTNode *ast, const char *path, LintDiag *out, int max) {
int lint_collect(ASTNode *ast, const char *path, const char *source,
LintDiag *out, int max) {
if (!ast || !out || max <= 0) return 0;
LintContext ctx = {0};
lint_run_checks(ast, path, &ctx);
lint_run_checks(ast, path, source, &ctx);
int n = ctx.warning_count < max ? ctx.warning_count : max;
for (int i = 0; i < n; i++) {
out[i].line = ctx.warnings[i].line;
Expand Down Expand Up @@ -1698,7 +1728,7 @@ int eigenscript_lint(const char *path, int json_mode, int fail_on_warning) {
}

LintContext ctx = {0};
lint_run_checks(ast, path, &ctx);
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
Expand Down
2 changes: 1 addition & 1 deletion tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2267,7 +2267,7 @@ fi
echo ""

# [81] Linter (--lint) — exercises lint.c, which had zero suite coverage.
echo "[81] Linter (13 checks)"
echo "[81] Linter (test_lint.sh, counted dynamically)"
LINT_OUTPUT=$(bash "$TESTS_DIR/test_lint.sh" </dev/null 2>&1)
LINT_PASS=$(echo "$LINT_OUTPUT" | grep -c "PASS:" || true)
LINT_FAIL=$(echo "$LINT_OUTPUT" | grep -c "FAIL:" || true)
Expand Down
52 changes: 52 additions & 0 deletions tests/test_lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,58 @@ OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_contains "human output carries [W001] code" "$OUTPUT" "warning\[W001\]"
rm -f "$TMPFILE"

# --- #460: '# lint: loaded-by <file>' — a library fragment lints against
# its composer's transitive binding set; unlike allow-file E003, a genuine
# typo in the fragment still fires. Unresolvable context fails open.
FRAGDIR=$(mktemp -d /tmp/lint_frag_XXXXXX)
cat > "$FRAGDIR/entry.eigs" << 'EIGS'
define helper(x) as:
return x + 1
load_file of "fragment.eigs"
EIGS
cat > "$FRAGDIR/fragment.eigs" << 'EIGS'
r is helper of 1
print of r
EIGS
OUTPUT=$($EIGS --lint "$FRAGDIR/fragment.eigs" 2>&1 || true)
check_contains "fragment standalone: E003 fires" "$OUTPUT" "E003.*undefined name 'helper'"
cat > "$FRAGDIR/fragment.eigs" << 'EIGS'
# lint: loaded-by entry.eigs
r is helper of 1
print of r
EIGS
OUTPUT=$($EIGS --lint "$FRAGDIR/fragment.eigs" 2>&1 || true)
check_not_contains "loaded-by kills the composition FP" "$OUTPUT" "E003"
cat > "$FRAGDIR/fragment.eigs" << 'EIGS'
# lint: loaded-by entry.eigs
r is helper of 1
q is no_such_name of 2
print of r
print of q
EIGS
OUTPUT=$($EIGS --lint "$FRAGDIR/fragment.eigs" 2>&1 || true)
check_contains "loaded-by keeps typo protection" "$OUTPUT" "E003.*undefined name 'no_such_name'"
check_not_contains "loaded-by silent on the composed name" "$OUTPUT" "undefined name 'helper'"
cat > "$FRAGDIR/fragment.eigs" << 'EIGS'
# lint: loaded-by missing.eigs
r is helper of 1
print of r
EIGS
OUTPUT=$($EIGS --lint "$FRAGDIR/fragment.eigs" 2>&1 || true)
check_not_contains "unresolvable loaded-by fails open" "$OUTPUT" "E003"
cat > "$FRAGDIR/sibling.eigs" << 'EIGS'
define assert_eq(args) as:
return args[0] == args[1]
EIGS
cat > "$FRAGDIR/fragment.eigs" << 'EIGS'
# lint: loaded-by sibling.eigs
ok is assert_eq of [1, 1]
print of ok
EIGS
OUTPUT=$($EIGS --lint "$FRAGDIR/fragment.eigs" 2>&1 || true)
check_not_contains "concat-sibling context binds (no loader needed)" "$OUTPUT" "E003"
rm -rf "$FRAGDIR"

# --- #459: W012/W013 derive from register_builtins(), not a hand list ---
# `dispatch` and `chr` were registered builtins missing from the old
# hand-copied BUILTINS[] array, so shadowing them lint'd clean.
Expand Down
Loading