Skip to content

Commit 911b2ba

Browse files
committed
Flag a top-level action form that is stored as data instead of run
MeTTa evaluates a top-level form only when it carries a leading `!`; every other form is added to the space as data. That is what you want for a fact or a rule, but an op whose signature returns the unit type (->) produces nothing a later query can match, so the stored call is inert: the assertion never checks, the add-atom never adds, the println! never prints. The failure is silent, which is why an unbanged assertEqualToResult reads as a passing test. The check reads the unit return type from the ops' own signatures rather than from a list of builtin names, so a user op declared (-> ... (->)) is covered on the same footing. It is a warning, so it never changes the check exit code, it skips an op that also carries a non-arrow type, and it defers to the arity error when the call matches no declared overload.
1 parent c9f3395 commit 911b2ba

2 files changed

Lines changed: 135 additions & 1 deletion

File tree

packages/core/src/diagnose.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,75 @@ describe("analyzeSource — imported declarations", () => {
150150
});
151151
});
152152

153+
describe("analyzeSource — top-level actions the interpreter never runs", () => {
154+
// MeTTa evaluates a top-level form only when it carries `!`; every other form is added to the space as
155+
// data. That is right for facts and rules, but an op whose signature returns the unit type `(->)` yields
156+
// nothing to match on, so storing the call is inert: the assertion never checks, the `add-atom` never adds,
157+
// the `println!` never prints. Confirmed against the runtime: a file holding `(assertEqual 1 2)` runs clean,
158+
// while `!(assertEqual 1 2)` reports `results-are-not-equal`.
159+
const actionsOf = (src: string) =>
160+
analyzeSource(src, cfg).filter((d) => d.code === "unevaluated-action");
161+
162+
it("flags an unbanged assertion and underlines the whole form", () => {
163+
const src = "(assertEqualToResult (retract a) (1))";
164+
const diags = actionsOf(src);
165+
expect(diags).toHaveLength(1);
166+
expect(diags[0]!.severity).toBe(DiagnosticSeverity.Warning);
167+
expect(diags[0]!.message).toContain("assertEqualToResult");
168+
expect(diags[0]!.range.start).toEqual({ line: 0, character: 0 });
169+
expect(diags[0]!.range.end).toEqual({ line: 0, character: src.length });
170+
});
171+
172+
it("suggests inserting `!` before the form", () => {
173+
const fix = actionsOf("(assertEqual 1 1)")[0]!.suggestions?.[0];
174+
expect(fix?.replacement).toBe("!");
175+
// a zero-width span at the form's start, so applying it inserts rather than replaces
176+
expect(fix?.span.start).toEqual({ line: 0, character: 0 });
177+
expect(fix?.span.end).toEqual({ line: 0, character: 0 });
178+
});
179+
180+
it("stays silent when the form carries its `!`", () => {
181+
expect(actionsOf("!(assertEqual 1 1)")).toEqual([]);
182+
});
183+
184+
it("stays silent when a comment sits between the `!` and its form", () => {
185+
// The parser binds a pending top-level `!` to the next form across comments, so this banner layout runs.
186+
expect(actionsOf("!;;; banner\n(assertEqual 1 1)")).toEqual([]);
187+
});
188+
189+
it("flags space mutation and output ops the same way", () => {
190+
expect(actionsOf("(add-atom &self (fact a))")).toHaveLength(1);
191+
expect(actionsOf('(println! "hi")')).toHaveLength(1);
192+
});
193+
194+
it("reads the unit return type from a user declaration, not a list of builtin names", () => {
195+
const diags = actionsOf("(: my-effect (-> Number (->)))\n(my-effect 1)");
196+
expect(diags).toHaveLength(1);
197+
expect(diags[0]!.range.start.line).toBe(1);
198+
});
199+
200+
it("reads it from an imported declaration too", () => {
201+
const imported = atomsOf("(: remote-log (-> String (->)))");
202+
expect(analyzeSource('(remote-log "hi")', cfg, imported)).toHaveLength(1);
203+
// without the signature in scope the head is untyped data, so nothing is reported
204+
expect(analyzeSource('(remote-log "hi")', cfg)).toEqual([]);
205+
});
206+
207+
it("leaves rules, type declarations, and doc atoms alone", () => {
208+
expect(actionsOf("(= (run) (assertEqual 1 1))")).toEqual([]);
209+
expect(actionsOf("(: assertEqual (-> Atom Atom (->)))")).toEqual([]);
210+
expect(actionsOf('(@doc my-add (@desc "adds"))')).toEqual([]);
211+
});
212+
213+
it("leaves plain data and untyped heads alone", () => {
214+
expect(actionsOf("(likes Sam pizza)")).toEqual([]);
215+
});
216+
217+
it("defers to the arity error when the call matches no declared overload", () => {
218+
expect(analyzeSource("(assertEqual 1)", cfg).map((d) => d.code)).toEqual(["arity-mismatch"]);
219+
});
220+
});
221+
153222
describe("importedDefinitions", () => {
154223
it("flattens a resolved import map and de-duplicates aliased module entries", () => {
155224
const module = {

packages/core/src/diagnose.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,67 @@ function checkArity(src: string, node: SpannedNode, env: MinEnv, out: Diagnostic
121121
});
122122
}
123123

124+
/** The unit type `(->)`: an arrow expression with no parameters and no result. The stdlib gives it as the
125+
* return type of every op that exists only for its effect, so a call to one yields nothing to match on. */
126+
function isUnitType(t: Atom | undefined): boolean {
127+
return (
128+
t?.kind === "expr" &&
129+
t.items.length === 1 &&
130+
t.items[0]?.kind === "sym" &&
131+
t.items[0].name === "->"
132+
);
133+
}
134+
135+
/** Does every arrow type declared for `name` return the unit type? True for the assert family,
136+
* `add-atom`/`remove-atom`/`add-reduct`, `print!`/`println!`, and any user op declared `(-> ... (->))`.
137+
* Read from the signatures themselves, so it needs no list of builtin names. */
138+
function isActionOp(env: MinEnv, name: string): boolean {
139+
const arrows = (env.types.get(name) ?? []).filter(
140+
(t) => typeHead(t) === "->" && t.kind === "expr" && t.items.length >= 2,
141+
);
142+
return (
143+
arrows.length > 0 &&
144+
arrows.every((t) => t.kind === "expr" && isUnitType(t.items[t.items.length - 1]))
145+
);
146+
}
147+
148+
/** A top-level action form the interpreter stores instead of running. MeTTa evaluates a top-level form only
149+
* when it carries `!`; every other form is added to the space as data. That is what you want for a fact or a
150+
* rule, but an op returning the unit type produces nothing a later query can match, so the stored call is
151+
* inert: the assertion never checks, the `add-atom` never adds, the `println!` never prints. The failure is
152+
* silent, which is why an unbanged `assertEqualToResult` reads as a passing test. */
153+
function checkUnevaluatedAction(
154+
src: string,
155+
node: SpannedNode,
156+
env: MinEnv,
157+
out: Diagnostic[],
158+
): void {
159+
if (node.bang === true || node.children === undefined) return;
160+
const name = headName(node);
161+
if (name === undefined) return;
162+
// An op that also carries a non-arrow type can legitimately be stored as data, matching checkArity.
163+
if (hasTupleType(env, name)) return;
164+
if (!isActionOp(env, name)) return;
165+
// A call matching no declared overload is already reported as an arity mismatch; that is the real problem.
166+
if (!declaredArities(env, name).has(node.children.length - 1)) return;
167+
const range = spanToRange(src, node.span.start, node.span.end);
168+
out.push({
169+
range,
170+
severity: DiagnosticSeverity.Warning,
171+
code: "unevaluated-action",
172+
message: `\`${name}\` runs for its effect, so this top-level form is stored as data and never evaluated`,
173+
relatedInformation: [{ message: "a top-level form runs only when it carries a leading `!`" }],
174+
suggestions: [
175+
{
176+
span: { start: range.start, end: range.start },
177+
replacement: "!",
178+
applicability: Applicability.MachineApplicable,
179+
message: "prefix it with `!` to run it",
180+
},
181+
],
182+
});
183+
}
184+
124185
/** Parameter types the interpreter passes UNEVALUATED: `Atom` (spec `metta`: `$type == Atom` returns the
125186
* argument as-is) and the `Variable`/`Expression` meta-types a form binds or matches. A call sitting at
126187
* such a position, a case/if/let branch, a match/unify pattern, or a quoted term, is data the interpreter
@@ -183,7 +244,11 @@ export function analyze(
183244
const known = config.undefinedSymbols ? knownNames(env) : new Set<string>();
184245
const matcher = config.undefinedSymbols ? new FuzzyMatcher(known) : undefined;
185246
const dataPositions = unevaluatedArgPositions(env);
186-
for (const node of cst) walk(src, node, env, config, known, matcher, dataPositions, false, out);
247+
for (const node of cst) {
248+
// Only a top-level node carries the `!` marker, so this check sits outside the recursive walk.
249+
checkUnevaluatedAction(src, node, env, out);
250+
walk(src, node, env, config, known, matcher, dataPositions, false, out);
251+
}
187252
const seen = new Set<string>();
188253
const deduped = out.filter((d) => {
189254
const k = `${d.range.start.line}:${d.range.start.character}:${d.code}`;

0 commit comments

Comments
 (0)