From 9a01a05ff35ec89f4b57c24f14bbed2cf298ff87 Mon Sep 17 00:00:00 2001 From: Mike Lowis Date: Thu, 1 Oct 2026 23:51:01 -0400 Subject: [PATCH] added simple if clauses --- nl-shell-harness-spec.md | 31 ++-- nlshell.rb | 349 +++++++++++++++++++++++++++++---------- test/nlshell_test.rb | 119 +++++++++++-- 3 files changed, 394 insertions(+), 105 deletions(-) diff --git a/nl-shell-harness-spec.md b/nl-shell-harness-spec.md index 330e478..d5504cd 100644 --- a/nl-shell-harness-spec.md +++ b/nl-shell-harness-spec.md @@ -56,12 +56,7 @@ graph TD LEX --> VAL{Unbalanced quote?} VAL -->|yes| REJ[Reject, ask to close quote] VAL -->|no| SEG[Sentences then clauses - SS 5] - SEG --> CLS[Classifier - regex/keyword, per clause] - CLS -->|action/chain| AN[ActionRecorder path] - CLS -->|loop/conditional| LN[Tree node path] - CLS -->|query| QN[Query path] - CLS -->|teach| TN[Teach path] - AN & LN --> T1Try[Tier 1 lookup] + SEG --> T1Try[Tier 1 lookup] T1Try -->|miss| T2Try[Tier 2 fuzzy match] T2Try -->|miss| T3Call[Tier 3: schema-constrained call] T3Call --> Validate[JSON Schema validate] @@ -243,11 +238,9 @@ Implemented as `Lexer.clauses`, driven by the ruleset rather than hardcoded: - Splits a sentence into clauses on connective token sequences declared via `connectives([...])` (builtins: `["after", "that"]`, `["and", "then"]`, `["then"]`, `["next"]`). - A bare `and` **without** a preceding comma is ambiguous ("copy a and b to c" is one command). The code only splits on it when the token right after `and` (or `, and`) is in the ruleset's `verb_patterns` set (a known verb word) **or** is a `:command` token (a recognized `$PATH` executable, e.g. "delete a.txt and ls") — not "post-canonicalization" since Tier 2 canonicalization doesn't exist yet; otherwise it's treated as a list argument within one clause. -- Each resulting clause is classified independently (§7 node types) via `Classifier.classify`. +- Each resulting clause is resolved independently via the same Tier 1→Tier 2(→Tier 3) pipeline as any other clause (§6.1) — there's no separate classification step; a `query`/`conditional`/`loop` phrase is just a tool whose rule happens to match it, same as any `action`. §7's node `type` comes from whichever tool resolves it, not from a pre-tagged clause kind. -**[A] still open, not yet implemented:** the REPL currently classifies and executes each clause as it's reached, rather than resolving the whole Utterance first. The plan-then-execute policy below remains a v-next item: - -- For a multi-clause Utterance: resolve every clause first (may hit Tier 3 per clause), assemble the full plan, show it once, then execute. Query and confirmed-teach clauses may run immediately since they don't mutate state; action clauses wait for the full plan to validate. If any clause fails to resolve, execute nothing. +**Partially implemented:** `Utterance.build` now resolves every clause first and executes nothing if any clause fails to resolve, closing the core gap this note used to describe. Still open: the query/confirmed-teach-clauses-may-run-immediately refinement, and Tier 3 escalation per clause (Tier 3 doesn't exist yet). ### 5.6 Fragments and the context register **[A]** @@ -309,6 +302,9 @@ graph TD **Steps** 1. **Normalize:** lowercase, strip filler words ("please", "the", "my"), split into tokens. + + **Proposal (not yet implemented):** derive the filler list from a standard English stop-word list minus whatever words appear literally in the current rule set (`STOP_WORDS - vocabulary`), recomputed whenever rules are reindexed, instead of hand-maintaining a fixed list. This generalizes past the handful of words anyone's thought to add, and self-updates as rules change, but only protects words that collide with *current* rule vocabulary — it does not protect words reserved for future use. The seed `STOP_WORDS` list must still exclude pronouns ("it", "them") and negation ("not", "no") up front, since §5.6's planned pronoun/ellipsis resolution needs those to survive normalization rather than be discarded here. + 2. **Typo correction:** for each token not in the known vocabulary (rule words + synonym terms), replace it with the nearest vocabulary word if edit distance ≤ 2 (`text` gem, Jaro-Winkler or Levenshtein). Leave the token alone if nothing is close. 3. **Synonym canonicalization:** rewrite each token to its canonical form using the `synonyms` table, e.g. `wipe → delete`. Both the input and the indexed rule patterns are canonicalized the same way, so they meet in the middle. 4. **Score:** compare the canonicalized input against every rule's canonicalized trigger phrase. @@ -333,6 +329,21 @@ Start conservative and tune from the audit log (`tier_used`, `confirmed`, and la --- +### 6.5 Control-flow tools (proposal, not yet implemented) + +Collapses the former `Classifier` pre-filter for `conditional`/`loop` into the same `Ruleset` tool + Tier 1/2/3 mechanism every other action uses, instead of a separate node kind with its own bespoke matching. `query` already folds in this way with no further changes needed — a query is just a tool whose action looks something up instead of running a destructive command. Loops/conditionals need actual new capability, scoped below: + +1. **DSL:** `ToolBuilder` gains a way to mark a slot as a *body* rather than a leaf value — e.g. `rule "if :condition then :body"` declares `:body` as recursive (a `body_slots :body` call, or a naming convention) — so `build` stops requiring every slot to bind one flat keyword to one leaf token. +2. **Extraction:** a body slot consumes *all* leftover tokens after the pattern's literal words are matched, not one leftover token per slot like today's `Tier1.match`/`Tier2.extract_slots` — a different extraction rule, specific to body slots. +3. **Recursive resolution:** the body span's tokens are re-split via `Lexer.clauses` and each resulting clause re-resolved via the same `Utterance.resolve` (Tier1→Tier2→Tier3) used for top-level clauses, producing a nested list of nodes — reusing the one pipeline rather than standing up a parallel one. +4. **Condition parsing:** `:condition` becomes a predicate struct (§7's `equals|contains|exists|greater_than|matches_pattern`, `left`/`right` as `$ref`/literal), not a leaf token — a parsing step beyond today's slot binding, needed regardless of the DSL change above. +5. **Executor:** `Executor.call` currently flattens every arg via `.text`; it needs a branch so a `:body`/`:then`/`:else` arg executes its nested nodes recursively instead of being passed as a string. +6. **Limits:** the depth cap (§7, 3) and loop iteration cap (§10, 20) must be enforced in this new recursive path, not just documented. + +Suggested first slice: a single `conditional` tool, a flat `:body` of one action, no `else`, no loops — validates the DSL/extraction/executor extensions before generalizing to full nesting and loops. + +--- + ## 7. Node Schemas (JSON Schema, abbreviated) ```jsonc diff --git a/nlshell.rb b/nlshell.rb index 09137e8..8d8283b 100755 --- a/nlshell.rb +++ b/nlshell.rb @@ -211,37 +211,11 @@ module Fuzzy end end -# Stage: CLS. Tags a clause as conditional/loop/query/action by matching its -# leading words against configured phrases, checked in the configured order -# (first match wins). teach/remember aren't a kind - they're builtin actions. -module Classifier - class << self - attr_accessor :patterns - end - - def self.configure(ruleset) - self.patterns = ruleset.classifier_patterns - end - - # Returns the matched kind, or nil if no configured phrase matches. - # TODO: fall through to Tier 3 (LLM) instead of nil once that tier exists. - def self.classify(clause) - patterns.each do |kind, phrases| - return kind if phrases.any? { |phrase| leading_match?(clause, phrase) } - end - nil - end - - def self.leading_match?(clause, phrase) - return false if clause.length < phrase.length - - clause.first(phrase.length).each_with_index.all? { |t, i| t.word? && Fuzzy.close?(t.text, phrase[i]) } - end -end - # Stage: Tier 1. Exact/template match against configured rule patterns. A # pattern is a string of literal words and :slot placeholders, matched # word-for-word against a clause (same length, no fuzzing - that's Tier 2). +# A trailing :slot the tool declared via body_slots is the one exception: +# it claims every remaining token as a nested clause, not one token. module Tier1 class << self attr_accessor :rules @@ -254,15 +228,41 @@ module Tier1 # Returns an action node (Hash) on an exact match, or nil on a miss. def self.lookup(clause) rules.each do |pattern, tool| - args = match(pattern.split, clause) + args = match(pattern.split, clause, tool.body_slots) return { type: "action", name: tool.name, args: args, tool: tool } if args end nil end - def self.match(words, clause) - return nil unless words.length == clause.length + def self.match(words, clause, body_slots = Set.new) + last = words.last + if last&.start_with?(":") && body_slots.include?(last.delete_prefix(":").to_sym) + match_with_body(words, clause, last) + else + return nil unless words.length == clause.length + + match_literals(words, clause) + end + end + + # Matches the pattern's fixed-length head normally, then hands every + # leftover token to the same Tier 1/2 resolution a top-level clause gets - + # so a conditional's body is just another resolved node, not a raw token. + def self.match_with_body(words, clause, body_word) + head = words[0..-2] + return nil if clause.length <= head.length # need at least one token for the body + args = match_literals(head, clause.first(head.length)) + return nil unless args + + body = Utterance.resolve(clause[head.length..]) + return nil unless body.node + + args[body_word.delete_prefix(":").to_sym] = body.node + args + end + + def self.match_literals(words, clause) args = {} words.each_with_index do |word, i| token = clause[i] @@ -281,8 +281,9 @@ module Tier1 end end -# Tier 2 canonicalization store (SS6.4 step 3): term -> canonical word. Tier 2 -# itself isn't built yet; "remember" populates this for when it is. +# Tier 2 canonicalization store (SS6.4 step 3): term -> canonical word, +# applied to both the query and rule vocabulary before Tier2 scores them. +# "remember" populates this at runtime. module Synonyms class << self attr_accessor :terms @@ -295,23 +296,183 @@ module Synonyms end end +# Stage: Tier 2 (SS6.4). Bag-of-words + TF-IDF cosine fallback for a Tier 1 +# miss - tolerates reordered/extra/filler words that Tier 1's strict, +# same-length positional match can't. Runs only after Tier1.lookup misses. +module Tier2 + # Carry no matching signal of their own; stripped before scoring. + FILLER_WORDS = %w[please the a an my your].freeze + + # SS6.4 decision thresholds, collapsed to accept/reject: there's no Tier 3 + # yet to escalate a medium-confidence score to. + ACCEPT_SCORE = 0.75 + ACCEPT_LEAD = 0.15 + + # Max edit distance for correcting a query word toward a known vocabulary + # word before scoring (SS6.4 step 2). + TYPO_MAX_DISTANCE = 2 + + class << self + attr_accessor :rules, :vocabulary, :idf, :vectors + end + + def self.configure(ruleset) + self.rules = ruleset.rule_index + reindex + end + + # Rebuilds vocabulary/idf/vectors from the current rule set. Call again + # after a rule is added at runtime (teach uses the same shared rules Hash). + def self.reindex + docs = rules.keys.to_h { |pattern| [pattern, literal_words(pattern)] } + self.vocabulary = docs.values.flatten.uniq + self.idf = compute_idf(docs.values) + self.vectors = docs.transform_values { |words| vectorize(words) } + end + + # Returns an action node Hash (same shape as Tier1.lookup), or nil if + # nothing scores high enough, or slot extraction can't bind cleanly. + def self.lookup(clause) + words = normalize(clause) + return nil if words.empty? + + query = vectorize(words) + scored = vectors.map { |pattern, vector| [pattern, cosine(query, vector)] }.sort_by { |_, score| -score } + return nil unless accept?(scored) + + pattern, = scored.first + tool = rules[pattern] + args = extract_slots(pattern, clause) + return nil unless args + + { type: "action", name: tool.name, args: args, tool: tool } + end + + # Top score must clear the floor AND lead the runner-up by a margin - a + # close second choice means the match is too ambiguous to auto-accept. + def self.accept?(scored) + return false if scored.empty? + + top = scored[0][1] + runner_up = scored[1]&.last || 0 + top >= ACCEPT_SCORE && (top - runner_up) >= ACCEPT_LEAD + end + + # Clause tokens -> normalized bag-of-words: literal tokens contribute their + # kind (Token#key, so different filenames score identically), filler words + # and punctuation are dropped, everything else is typo-corrected then + # synonym-canonicalized. + def self.normalize(clause) + clause.reject(&:punct?) + .map(&:key) + .reject { |w| FILLER_WORDS.include?(w) } + .map { |w| Synonyms.canonical(correct(w)) } + end + + # Nearest known vocabulary word within TYPO_MAX_DISTANCE, or the word + # itself if nothing is close enough to count as a typo of it. + def self.correct(word) + return word if vocabulary.include?(word) + + nearest = vocabulary.min_by { |v| Fuzzy.distance(word, v) } + return word unless nearest && Fuzzy.distance(word, nearest) <= TYPO_MAX_DISTANCE + + nearest + end + + def self.literal_words(pattern) + pattern.split.reject { |w| w.start_with?(":") } + end + + # Smoothed IDF (as in scikit-learn): +1 inside the log avoids a zero/div-by- + # zero for a term in every document; +1 outside keeps every term's weight + # positive even if it appears in all of them. + def self.compute_idf(docs) + total = docs.length + vocabulary.to_h do |term| + doc_freq = docs.count { |words| words.include?(term) } + [term, Math.log((total + 1).fdiv(doc_freq + 1)) + 1] + end + end + + def self.vectorize(words) + counts = words.each_with_object(Hash.new(0)) { |w, h| h[w] += 1 } + counts.each_with_object({}) { |(term, count), v| v[term] = count * idf.fetch(term, 0) } + end + + def self.cosine(a, b) + dot = a.sum { |term, weight| weight * b.fetch(term, 0) } + mag_a = Math.sqrt(a.values.sum { |w| w**2 }) + mag_b = Math.sqrt(b.values.sum { |w| w**2 }) + return 0.0 if mag_a.zero? || mag_b.zero? + + dot / (mag_a * mag_b) + end + + # SS6.4 step 6: tokens not consumed matching a literal pattern word fill + # the pattern's :slots, in the order both appear. Returns nil if the + # leftover count doesn't match the slot count - a high-scoring pattern + # that can't bind cleanly isn't a usable match. + def self.extract_slots(pattern, clause) + literals = literal_words(pattern) + slots = pattern.split.select { |w| w.start_with?(":") }.map { |w| w.delete_prefix(":").to_sym } + + leftovers = [] + # Each non-filler clause token either consumes one remaining literal + # pattern word (fuzzy match, synonym-aware) or becomes a candidate slot + # value; filler words carry no matching signal and are dropped outright. + clause.reject(&:punct?).each do |token| + next if FILLER_WORDS.include?(token.key) + + index = literals.find_index { |word| token.word? && Fuzzy.close?(Synonyms.canonical(token.text), word) } + if index + literals.delete_at(index) + else + leftovers << token + end + end + + return nil unless leftovers.length == slots.length + + slots.zip(leftovers).to_h + end +end + # Stage: EX. Runs a resolved tool's action, isolating the REPL from any # exception an action raises so one bad command can't crash the shell. module Executor + # Only a Token needs flattening to its raw text - a :body/:then/:else arg + # is already a resolved node Hash, meant for the action to execute itself. def self.call(tool, args) - tool.action.call(**args.transform_values(&:text)) + tool.action.call(**args.transform_values { |v| v.is_a?(Lexer::Token) ? v.text : v }) true rescue StandardError => e puts " error running #{tool.name}: #{e.message}" false end + + # Confirms first for an :ask tool, same policy a top-level clause gets - the + # one gate between "resolved" and "actually runs", so a conditional's body + # can't bypass permission checking. + def self.run(node) + tool = node[:tool] + if tool.permission == :ask + print " run #{tool.name}? [y/N] " + return false unless gets&.chomp&.downcase == "y" + end + + call(tool, node[:args]) + end end # Stage 0 result (spec SS5.7). Everything downstream (Tier 1/2/3, executor) -# operates on this, never on raw text or loose token/clause arrays. +# operates on this, never on raw text or loose token/clause arrays. A clause's +# kind is no longer tagged up front by a separate classifier - every clause +# is resolved directly through the same Tier 1/2(/3) pipeline, so node/tier +# are nil together on a miss. module Utterance - Clause = Struct.new(:tokens, :kind) + Clause = Struct.new(:tokens, :node, :tier) Result = Struct.new(:raw, :clauses) # SS5.8 limits. @@ -335,17 +496,30 @@ module Utterance clauses = sentences.flat_map { |sentence| Lexer.clauses(sentence) } raise Rejected, "that's too many clauses in one request, please split it up" if clauses.length > MAX_CLAUSES - # Classification/Tier 1 tolerate small typos on their own (Fuzzy.close? - # inside Classifier.leading_match? and Tier1.match), so no separate - # correction pass is needed here. - Result.new(raw, clauses.map { |tokens| Clause.new(tokens, Classifier.classify(tokens)) }) + # Tier 1/2 tolerate small typos on their own (Fuzzy.close? inside + # Tier1.match and Tier2's typo correction), so no separate pass is needed. + Result.new(raw, clauses.map { |tokens| resolve(tokens) }) + end + + # Tries Tier 1 first (strict positional match), falling back to Tier 2 + # (fuzzy bag-of-words) only on a Tier 1 miss - tags which tier resolved it + # so the REPL can report that, or nil/nil if neither did. + def self.resolve(tokens) + if (node = Tier1.lookup(tokens)) + Clause.new(tokens, node, :tier1) + elsif (node = Tier2.lookup(tokens)) + Clause.new(tokens, node, :tier2) + else + Clause.new(tokens, nil, nil) + end end end module Ruleset # One tool: the rule patterns that trigger it, the keyword-arg action that - # runs it, and whether running it needs user confirmation first. - Tool = Struct.new(:name, :rules, :action, :permission) + # runs it, whether running it needs user confirmation first, and which + # slot names (if any) are bodies - see ToolBuilder#body_slots. + Tool = Struct.new(:name, :rules, :action, :permission, :body_slots) # A pattern's :slot names (the "target" in "copy :target to :destination"). def self.slot_names(pattern) @@ -378,6 +552,7 @@ module Ruleset @verb_synonyms = [] @permission = :ask # fail-safe default: a tool must opt into :yolo @action = nil + @body_slots = Set.new end def rule(pattern) @@ -404,6 +579,14 @@ module Ruleset @permission = level end + # Declares which of this tool's :slots are bodies: a nested clause + # (resolved through the same Tier 1/2 pipeline as any top-level clause), + # not a single leaf token. Must be the last slot in any rule that uses it + # - Tier1.match can only claim a trailing run of tokens, not a middle one. + def body_slots(names) + @body_slots.merge(names) + end + def action(&block) @action = block end @@ -417,7 +600,8 @@ module Ruleset raise ArgumentError, "tool #{@name.inspect}: rules disagree on slot names" unless slots.uniq.length == 1 raise ArgumentError, "tool #{@name.inspect}: action params don't match rule slots" unless slots.first == required_keywords - Tool.new(@name, patterns, @action, @permission) + validate_body_slots(patterns) + Tool.new(@name, patterns, @action, @permission, @body_slots) end private @@ -433,6 +617,19 @@ module Ruleset def required_keywords @action.parameters.select { |type, _| type == :keyreq }.map { |_, name| name }.to_set end + + # A body slot not in trailing position would silently never match - + # Tier1.match_with_body only ever claims a trailing run of tokens. + def validate_body_slots(patterns) + patterns.each do |pattern| + words = pattern.split + @body_slots.each do |slot| + next unless words.include?(":#{slot}") + + raise ArgumentError, "tool #{@name.inspect}: body slot :#{slot} must be last in #{pattern.inspect}" unless words.last == ":#{slot}" + end + end + end end class RulesModule @@ -448,10 +645,6 @@ module Ruleset @connectives = list end - def classifiers(hash) - @classifiers = hash - end - def tool(name, &block) builder = ToolBuilder.new(name) builder.instance_eval(&block) @@ -469,10 +662,6 @@ module Ruleset Set.new(@rules.keys.map { |pattern| pattern.split.first }) end - # :action phrases come from verbs, not the classifiers() call, so the - # two lists can't drift apart. - def classifier_patterns = @classifiers.merge(action: verb_patterns.to_a.map { |v| [v] }) - private # Last tool to claim a rule pattern wins; shadowing is never silent @@ -511,22 +700,6 @@ builtins = Ruleset.create("builtins") do ["next"], ] - # Checked in this order; first matching kind wins. teach/remember are not a - # kind of their own - they're builtin tools, classified as :action like any - # other verb (see the "teach"/"remember" tools below). - classifiers( - conditional: [ - ["if"], ["unless"], ["when"] - ], - loop: [ - ["for", "each"], ["repeat"], ["while"], ["every"] - ], - query: [ - ["what"], ["how"], ["why"], ["who"], ["where"], ["is"], ["are"], ["does"], ["list"], ["show"] - ], - # action: derived from verbs, below. - ) - tool("delete") do permission :ask @@ -571,6 +744,23 @@ builtins = Ruleset.create("builtins") do end end + # "then" is already a clause-splitting connective (SS5.5), so it can't also + # be a literal word here - "do" joins condition and body instead. + tool("conditional") do + permission :yolo # never destructive itself; the body's own permission gates that, via Executor.run + + rule "if :target exists do :body" + body_slots %i[body] + + action do |target:, body:| + if File.exist?(target) + Executor.run(body) + else + puts " condition false, skipping" + end + end + end + tool("teach") do permission :ask @@ -592,7 +782,7 @@ builtins = Ruleset.create("builtins") do next end - Classifier.configure(builtins) # refresh so the new leading word classifies as :action + Tier2.reindex # new pattern's literal words join Tier 2's vocabulary puts " learned: #{trigger.inspect} -> #{tool.name}" end end @@ -610,8 +800,8 @@ builtins = Ruleset.create("builtins") do end Lexer.configure(builtins) -Classifier.configure(builtins) Tier1.configure(builtins) +Tier2.configure(builtins) # Only run the REPL when executed directly, so tests can require this file # for its modules/ruleset without blocking on stdin. @@ -629,27 +819,18 @@ if __FILE__ == $PROGRAM_NAME end next if utterance.nil? # empty/punctuation-only input: no-op - if utterance.clauses.any? { |clause| clause.kind.nil? } + # Every clause must resolve before any of them run - a later clause + # failing shouldn't leave an earlier one already executed. + if utterance.clauses.any? { |clause| clause.node.nil? } puts "couldn't tell what kind of request this is, please rephrase" next end utterance.clauses.each_with_index do |clause, j| - puts "clause #{j} [#{clause.kind}]: #{clause.tokens.map(&:to_s).join(' ')}" - next unless clause.kind == :action - - node = Tier1.lookup(clause.tokens) - puts " #{node ? "tier1 hit: #{node}" : 'tier1 miss'}" - next unless node - - tool = node[:tool] - # :ask tools must be confirmed before running; :yolo tools run immediately. - if tool.permission == :ask - print " run #{tool.name}? [y/N] " - next unless gets&.chomp&.downcase == "y" - end + puts "clause #{j}: #{clause.tokens.map(&:to_s).join(' ')}" + puts " #{clause.tier} hit: #{clause.node}" - Executor.call(tool, node[:args]) + Executor.run(clause.node) end puts "match: #{Lexer.match_text(utterance.clauses.flat_map(&:tokens))}" end diff --git a/test/nlshell_test.rb b/test/nlshell_test.rb index 5102ce2..9f0fef0 100644 --- a/test/nlshell_test.rb +++ b/test/nlshell_test.rb @@ -64,20 +64,26 @@ class FuzzyTest < Minitest::Test end end -class ClassifierTest < Minitest::Test - def test_classifies_known_verb_as_action - tokens = Lexer.downcase_words(Lexer.tokenize("delete old.log")) - assert_equal :action, Classifier.classify(tokens) +class UtteranceTest < Minitest::Test + def test_resolves_a_known_verb_via_tier1 + clause = Utterance.build("delete old.log").clauses.first + assert_equal :tier1, clause.tier + assert_equal "delete", clause.node[:name] end - def test_classifies_query_keyword - tokens = Lexer.downcase_words(Lexer.tokenize("what is the time")) - assert_equal :query, Classifier.classify(tokens) + # Regression: query/conditional/loop keywords no longer get a free pass + # through a separate classifier - with no tool registered for "what", this + # must fail to resolve exactly like any other unmatched verb. + def test_unmatched_leading_word_leaves_the_clause_unresolved + clause = Utterance.build("what is the time").clauses.first + assert_nil clause.node + assert_nil clause.tier end - def test_unknown_leading_word_is_unclassified - tokens = Lexer.downcase_words(Lexer.tokenize("bork the garble")) - assert_nil Classifier.classify(tokens) + def test_unknown_verb_leaves_the_clause_unresolved + clause = Utterance.build("bork the garble").clauses.first + assert_nil clause.node + assert_nil clause.tier end end @@ -101,6 +107,56 @@ class Tier1Test < Minitest::Test node = Tier1.lookup(tokens) assert_equal "delete", node[:name] end + + # A :body slot must claim every remaining token as a nested resolved node, + # not bind to exactly one token like an ordinary :slot. + def test_lookup_resolves_a_body_slot_as_a_nested_node + tokens = Lexer.downcase_words(Lexer.tokenize("if nlshell.rb exists do archive nlshell.rb")) + node = Tier1.lookup(tokens) + refute_nil node + assert_equal "conditional", node[:name] + assert_equal "archive", node[:args][:body][:name] + end + + # Not enough tokens left for the body slot to bind anything - a miss, not + # a crash. + def test_lookup_misses_when_body_slot_has_nothing_to_bind + tokens = Lexer.downcase_words(Lexer.tokenize("if nlshell.rb exists do")) + assert_nil Tier1.lookup(tokens) + end +end + +class Tier2Test < Minitest::Test + # A filler word breaks Tier 1's same-length positional match, but Tier 2's + # bag-of-words score should still resolve it. + def test_lookup_resolves_past_a_filler_word_tier1_misses_on + tokens = Lexer.downcase_words(Lexer.tokenize("delete the old.log")) + assert_nil Tier1.lookup(tokens) + + node = Tier2.lookup(tokens) + refute_nil node + assert_equal "delete", node[:name] + assert_equal "old.log", node[:args][:target].text + end + + def test_lookup_rejects_unrelated_input + tokens = Lexer.downcase_words(Lexer.tokenize("what time is it")) + assert_nil Tier2.lookup(tokens) + end + + # Regression: extract_slots must drop filler words from the leftover count, + # not just from the score-time bag-of-words, or a real match's slot count + # comes out wrong and gets rejected. + def test_extract_slots_ignores_filler_words + tokens = Lexer.downcase_words(Lexer.tokenize("delete the old.log")) + args = Tier2.extract_slots("delete :target", tokens) + assert_equal({ target: tokens.last }, args) + end + + def test_extract_slots_rejects_a_leftover_count_mismatch + tokens = Lexer.downcase_words(Lexer.tokenize("delete old.log now")) + assert_nil Tier2.extract_slots("delete :target", tokens) + end end class RulesetTest < Minitest::Test @@ -176,7 +232,7 @@ class SharedRegistrationTest < Minitest::Test end class ExecutorTest < Minitest::Test - Tool = Struct.new(:name, :action) + Tool = Struct.new(:name, :action, :permission) def test_returns_true_on_success tool = Tool.new("noop", ->(target:) { target }) @@ -193,6 +249,25 @@ class ExecutorTest < Minitest::Test refute Executor.call(tool, { target: token }) end + + # Regression: a :body arg (a resolved node Hash, not a Token) must reach + # the action unchanged - only Token args get flattened to their raw text. + def test_call_passes_non_token_args_through_unchanged + captured = nil + tool = Tool.new("wrap", ->(body:) { captured = body }) + node = { type: "action", name: "noop" } + + Executor.call(tool, { body: node }) + assert_same node, captured + end + + # :yolo tools run immediately - no stdin prompt, so this can't hang a test. + def test_run_executes_a_yolo_tool_without_confirmation + tool = Tool.new("noop", ->(target:) { target }, :yolo) + token = Lexer::Token.new(:literal, "old.log", :file) + + assert Executor.run({ tool: tool, args: { target: token } }) + end end # End-to-end smoke tests driving the actual REPL process over stdin/stdout. @@ -218,4 +293,26 @@ class ReplIntegrationTest < Minitest::Test out = run_repl(%(delete old.log\nn\n)) refute_match(/Deleting old\.log/, out) end + + def test_unresolvable_input_reports_couldnt_tell + out = run_repl(%(bork the garble\n)) + assert_match(/couldn't tell what kind of request this is/, out) + end + + def test_conditional_runs_body_when_condition_is_true + out = run_repl(%(if nlshell.rb exists do archive nlshell.rb\n)) + assert_match(/Archiving nlshell\.rb/, out) + end + + def test_conditional_skips_body_when_condition_is_false + out = run_repl(%(if nope-definitely-missing.log exists do archive nope-definitely-missing.log\n)) + refute_match(/Archiving/, out) + end + + # The body's own permission still gates it - a conditional can't be used to + # bypass confirmation on a destructive action. + def test_conditional_body_still_requires_its_own_confirmation + out = run_repl(%(if nlshell.rb exists do delete nlshell.rb\nn\n)) + refute_match(/Deleting/, out) + end end -- 2.55.0