From 7dce9f550bac477c9a22e415c95afddeced35c32 Mon Sep 17 00:00:00 2001 From: Mitchell Paulus Date: Thu, 9 Jul 2026 21:35:32 -0500 Subject: [PATCH 1/2] Implement tryAs --- CHANGELOG.md | 9 + ai/json_boundary_casts.md | 118 +++++++++ doc/mshell.md | 29 ++- doc/type_system.inc.html | 38 +++ mshell/Evaluator.go | 51 +++- mshell/Lexer.go | 6 + mshell/Lexer_test.go | 3 + mshell/Parser.go | 2 + mshell/TypeCast.go | 31 +++ mshell/TypeCheckProgram.go | 7 + mshell/TypeExpr.go | 244 +++++++++++++++--- mshell/TypeParseIntegration.go | 50 +++- tests/success/tryas.msh | 68 +++++ tests/success/tryas.msh.stdout | 14 + .../tryas_statically_impossible.msh | 6 + 15 files changed, 633 insertions(+), 43 deletions(-) create mode 100644 ai/json_boundary_casts.md create mode 100644 tests/success/tryas.msh create mode 100644 tests/success/tryas.msh.stdout create mode 100644 tests/typecheck_fail/tryas_statically_impossible.msh diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f91771..a4aa83f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `tryAs` checked cast: ` tryAs ` validates the value against the + type at runtime and pushes a `Maybe` — `just value` when it conforms, `none` + when it does not. Unlike `as` (a static-only checker hint), `tryAs` is meant + for data crossing a trust boundary such as `parseJson` output: one check at + the boundary and everything downstream is precisely typed. Compose with the + existing `?` unwrap for a die-loud form, e.g. + `"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map`. + A `tryAs` whose source type could never be the target is a type error. + - CLI completions for `cargo`: subcommands (including installed third-party ones via `cargo --list`), per-subcommand options, and dynamic values for `--target`, `--features`, `-p`/`--package`, `--bin`/`--example`/`--test`/`--bench` diff --git a/ai/json_boundary_casts.md b/ai/json_boundary_casts.md new file mode 100644 index 00000000..6dfed788 --- /dev/null +++ b/ai/json_boundary_casts.md @@ -0,0 +1,118 @@ +# Checked casts at the JSON boundary (`tryAs`) + +Status: implemented on branch `try-as` (2026-07-09). Doc pattern + `tryAs` +both landed. One finding from implementation: `parseJson` maps every JSON +number to `MShellFloat` (Go's default unmarshal), so `tryAs {str: int}` on +JSON counts is always `none` — validate JSON numbers as `float`. Whether +`parseJson` should produce `int` for integral numbers is an open follow-up. + +## Problem + +Drilling into parsed JSON under the type checker is painful. +Extracting `.packages[].name` took four nested `match` blocks, +because `parseJson` returns the wide union `list|dict|numeric|str|bool|null` +and every union member must be handled at every level. +Each `match dict:` arm proves a fact, uses it once, and throws the proof away — +boilerplate scales with nesting depth. + +## What already works (and the doc gap) + +The static side of the fix already exists: + +```mshell +type Manifest = {packages: [{name: str}]} + +"pkgs.json" parseJson as Manifest :packages? (:name?) map +``` + +This type-checks and runs today. `type_system.inc.html` even recommends the +pattern ("prefer adding a named type and an `as` assertion near the boundary"), +but `doc/mshell.md` — the doc agents actually read — never shows +`parseJson ... as T`. **Cheapest immediate action: document this pattern in +`doc/mshell.md`.** + +## The gap: `as` is static-only + +`as` is a checker hint with no runtime work (`Evaluator.go`, `MShellAsCast` +case). When the JSON doesn't match the assertion, the failure surfaces late +and badly: `{"packages": [{"name": 42}]}` through the cast above dies with +`Cannot get length of a Float.` at a downstream call site, not at the boundary. + +## Design: `tryAs` — parse, don't validate + +One new checked cast that returns proof as a more precise type: + +```mshell +"pkgs.json" parseJson tryAs Manifest # ( -- Maybe[Manifest]), recoverable +"pkgs.json" parseJson tryAs Manifest ? # unwrap-or-die, composes with existing ? +``` + +- Runtime: structural walk of the value against the reified type expression. + JSON values are only dicts/lists/scalars, so the validator is a small + recursive function and structural checking is complete in this domain. +- Success: `just` wrapping the value, statically typed `T`. + Downstream `:field?` / `map` need no further matching. +- Failure: `none`. + +### Naming rationale + +- `as?` rejected: `?` consistently means *unwrap* a Maybe (`?`, `:field?`), + so a Maybe-*returning* `as?` reads backwards. +- `as!` rejected: `!` means *store to variable* (`name!`). +- `tryAs` follows the getter convention: base word returns Maybe, + existing `?` unwraps. Die-loud form is derived (`tryAs T ?`), not a + second primitive. + +### Division of labor with static `as` + +Both stay; the dividing line is trust boundaries: + +- `as` — value originates inside typed code: empty/ambiguous literals + (`[] as [str]`), narrowing inferred unions, branding. Checker can see + everything; runtime check would verify the statically obvious. Free. +- `tryAs` — value crosses in from outside (parseJson, process output, + spreadsheets). Only the runtime can know the shape; pay one walk, + get a `Maybe[T]` proof. + +Possible follow-on: once `tryAs` exists, a hint diagnostic when bare `as` +narrows an external-data union (e.g. the parseJson result) to a shape — +"did you mean tryAs?" — while leaving literal-hint uses untouched. + +## Deferred (deliberately) + +- **Path-precise error messages.** `tryAs ... ?` failing as a bare `none` + loses ".packages[3].name: expected str, got float". A future Result type + with an error string is the planned home for this; the validator computes + the message internally anyway, so a Result-returning variant just exposes + it. The "no message on bad unwrap" problem is bigger than JSON and is not + being solved here. +- **`dig` with typed default** (`json ['packages' 0 'name'] "unknown" dig`, + signature `(json [str|int] a -- a)`, default's type = result type via + existing generics). Nice for one-off drills without declaring a type. + If built: no `'*'` wildcard segments (they change the result shape and + break `a -- a`); `tryAs` + `map` owns extract-many. Conflates missing + with the sentinel value. +- **Recursive types** (`type Json = int | str | [Json]`) don't work today — + the name isn't in scope inside its own body. A first-class "any JSON" + type would need checker work; with checked casts at the boundary it's + rarely needed. + +## Implementation notes + +Structure (after review feedback on exhaustiveness): there is no separate +validator file. `TypeExpr.go` defines a `TypeExpression` interface +(`MShellParseItem` + `validateObj`) that every type-expression node must +implement, and the parser productions return it — so a new node kind +without runtime validation does not compile. Built-in named types (bytes, +null, path, datetime, Grid, GridView, GridRow) live in one +`builtinNamedTypes` table consulted by BOTH the checker's resolveTypeExpr +and the runtime validator, so a type cannot be half-added; a name missing +from the table does not exist statically either. `Maybe` (parametric) and +`none` (constructor, not a type) are deliberately special-cased outside +the table. + +- Type declarations are currently erased at runtime (`MShellTypeDecl` is + static-only). `tryAs` needs type expressions reified into the evaluator + so the validator can walk them. +- The shape language already covers everything JSON needs: shapes, optional + fields (`name?: T`), unions, `null` vs `none`, `Maybe`. diff --git a/doc/mshell.md b/doc/mshell.md index 4b64c3e1..f39ddc1a 100644 --- a/doc/mshell.md +++ b/doc/mshell.md @@ -564,6 +564,33 @@ type Row = [Cell] { "name": "Ada", "age": 36 } as Person :age? 1 + ``` +`as` is a static-only assertion: a hint to the checker with no runtime work. +Use it when the value originates inside typed code and the checker just needs help — typing an empty literal (`[] as [str]`), narrowing an inferred union, or tagging a literal into a named type. + +`tryAs` is the checked cast for data crossing a trust boundary (parseJson output, process output, spreadsheet rows). +It validates the value against the type at runtime and pushes a `Maybe`: `just value` when it conforms, `none` when it does not. +This is the "parse, don't validate" pattern: one check at the boundary, and everything downstream is precisely typed with no match boilerplate. +Compose with the existing `?` unwrap for the die-loud form. + +```mshell +type Manifest = {packages: [{name: str}]} + +# Recoverable: handle bad input with one match at the boundary. +"pkgs.json" parseJson tryAs Manifest match + just m : @m :packages? (:name?) map, + none : [], +end + +# Die-loud: unwrap immediately when malformed input should stop the script. +"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map +``` + +A `tryAs` whose source type can never be the target (e.g. `5 tryAs str`) is flagged by the checker, since it would always produce `none`. +Shape validation allows extra keys, requires all non-optional fields, and validates optional fields only when present. +A `none` conforms to `Maybe[T]` for every `T`; quotation types are checked by kind only (signatures are not verified at runtime). +Named types must be declared before the cast runs. +Note that `parseJson` produces a `float` for every JSON number, so validate JSON numbers as `float`, not `int`. + Dictionary types are split into homogeneous dictionaries and shapes. A homogeneous dictionary is for dynamic keys where every value has the same type. In a type expression, write `{str: int}`. @@ -1060,7 +1087,7 @@ end wl # Output: 11 - `gridValues`: Extract Grid or GridView cell values as row-major lists, without a header row and without coercing cell types. (`Grid|GridView -- [[a]]`) - `toCsvCell`: Escape a single CSV cell. If the value contains `,`, `"`, or a newline, wraps the value in double quotes and doubles any embedded quotes; otherwise returns the input unchanged. (`str -- str`) - `toCsv`: Serialize a list of rows to a CSV string. Each cell is escaped with `toCsvCell`, cells are joined with `,`, and rows are joined with `\n`. (`[[str]] -- str`) -- `parseJson`: Parse JSON from a string, binary, or file path into mshell objects. JSON `null` becomes the `null` type (distinct from `none`). (`path|str|binary -- list|dict|numeric|str|bool|null`) +- `parseJson`: Parse JSON from a string, binary, or file path into mshell objects. JSON `null` becomes the `null` type (distinct from `none`); every JSON number becomes a `float`. Narrow the result with `tryAs` (checked, boundary data) or `as` (static hint). (`path|str|binary -- list|dict|numeric|str|bool|null`) - `parseExcel`: Parse an `.xlsx` (OOXML) spreadsheet into a list of sheets in workbook (tab) order. Each sheet is a dict with a `name` key (the worksheet name), a `data` key holding a rectangular list of rows (list of lists), a `hidden` key (bool; `true` for hidden or veryHidden sheets), and a `visibility` key (`"visible"`, `"hidden"`, or `"veryHidden"`). Cell values are typed: numbers become floats (dates appear as Excel serial floats), strings become strings (shared, inline, and formula-string results all resolved), booleans become booleans, error cells (e.g. `#DIV/0!`) become `none`, and empty/padding cells are the empty string. Chartsheets are skipped; hidden worksheets are included. Dates are returned as raw Excel serial floats; apply `fromOleDate` at the call site to convert. `parseExcel` assumes the default 1900-based date system, which matches `fromOleDate`'s OLE epoch (1899-12-30). Workbooks saved with the 1904 date system (``, seen on some files originally authored on older Mac Excel or with the "Use 1904 date system" option enabled) have serials offset by 1462 days; on those files, add 1462 to each serial before calling `fromOleDate`, e.g. `@wb :0: :data? :3: :0: 1462 + fromOleDate`. (`path|binary -- list`) - `seq`: Generate a list of integers, starting from 0. Exclusive end to integer on stack. `2 seq` produces `[0 1]`. `(int -- [int])` - `repeat`: Create a list containing the provided value repeated `n` times. `(a int -- [a])` diff --git a/doc/type_system.inc.html b/doc/type_system.inc.html index bae627c4..100c430a 100644 --- a/doc/type_system.inc.html +++ b/doc/type_system.inc.html @@ -153,6 +153,44 @@

Type Expressions { "name": "Ada", "age": 36 } as Person +

Checked casts with tryAs §

+ +

+as is a static-only assertion: a hint to the checker with no runtime work. +That is the right tool when the value originates inside typed code — typing an empty literal ([] as [str]), narrowing an inferred union, or tagging a literal into a named type — because the checker can already see everything it needs. +

+ +

+tryAs is the checked cast for data crossing a trust boundary, such as parseJson output, process output, or spreadsheet rows. +It validates the value against the type expression at runtime and pushes a Maybe: just value when the value conforms, none when it does not. +This is the "parse, don't validate" pattern: one check at the boundary hands the checker a precise type, so everything downstream needs no further matching. +The existing ? unwrap composes for the die-loud form. +

+ +
type Manifest = {packages: [{name: str}]}
+
+# Recoverable: handle bad input with one match at the boundary.
+"pkgs.json" parseJson tryAs Manifest match
+    just m : @m :packages? (:name?) map,
+    none   : [],
+end
+
+# Die-loud: unwrap immediately when malformed input should stop the script.
+"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map
+ +

+A tryAs whose source type can never be the target (for example 5 tryAs str) is a type error, since the runtime check would always produce none. +

+ +

+Validation semantics mirror the match block's type arms. +int matches an integer only — note that parseJson produces a float for every JSON number, so validate JSON numbers as float. +Shape validation allows extra keys (width subtyping), requires all non-optional fields, and validates optional fields only when present. +A none conforms to Maybe[T] for every T; a just validates its inner value. +Quotation types are checked by kind only — signatures are not verified at runtime. +A named type must have been declared before the cast runs. +

+

Dictionaries § Back to top

diff --git a/mshell/Evaluator.go b/mshell/Evaluator.go index ab2f8854..e5f16aee 100644 --- a/mshell/Evaluator.go +++ b/mshell/Evaluator.go @@ -396,6 +396,11 @@ type EvalState struct { defIndex map[string]int defIndexLen int + + // typeDefs is the runtime type environment: `type Name = ` + // declarations register their body AST here so `tryAs Name` can + // resolve named types during validation. + typeDefs map[string]TypeExpression } // RebuildDefinitionIndex records the first index for each name, matching @@ -906,18 +911,54 @@ func (state *EvalState) processToken(token MShellParseItem, frame *EvaluationFra return state.processTokenToken(t, frame, frames) case *MShellTypeDecl: - // Static-only: type declarations have no runtime effect by design. + // Registers the declaration for `tryAs`; otherwise static-only. + state.registerTypeDecl(t) return SimpleSuccess() case *MShellAsCast: // Static-only: `as` is a checker hint; no runtime work. return SimpleSuccess() + case *MShellTryAsCast: + return state.processTryAsCast(t, stack) + default: return state.FailWithMessage(fmt.Sprintf("Unknown token type: %T\n", token)) } } +// registerTypeDecl records a `type Name = ` declaration in the +// runtime type environment for `tryAs`. The checker flags duplicate +// declarations; at runtime the latest evaluation wins. +func (state *EvalState) registerTypeDecl(decl *MShellTypeDecl) { + if state.typeDefs == nil { + state.typeDefs = make(map[string]TypeExpression, 8) + } + state.typeDefs[decl.Name] = decl.Body +} + +// processTryAsCast implements the runtime of `tryAs`: pop a value, +// validate it structurally against the type expression, and push +// `just value` when it conforms or `none` when it does not. The +// validation error return is reserved for malformed casts (an unknown +// type name), which fail the script rather than producing a `none`. +func (state *EvalState) processTryAsCast(cast *MShellTryAsCast, stack *MShellStack) EvalResult { + obj, err := stack.Pop() + if err != nil { + return state.FailWithMessage(fmt.Sprintf("%d:%d: 'tryAs' requires a value on the stack.\n", cast.TryAsToken.Line, cast.TryAsToken.Column)) + } + conforms, verr := cast.Target.validateObj(state, obj) + if verr != nil { + return state.FailWithMessage(fmt.Sprintf("%d:%d: %s\n", cast.TryAsToken.Line, cast.TryAsToken.Column, verr.Error())) + } + if conforms { + stack.Push(&Maybe{obj: obj}) + } else { + stack.Push(&Maybe{obj: nil}) + } + return SimpleSuccess() +} + // callDefinition handles calling a definition with TCO support func (state *EvalState) callDefinition(def MShellDefinition, token Token, frame *EvaluationFrame, frames *[]EvaluationFrame) EvalResult { newContext := frame.Context.CloneLessVariables() @@ -3222,8 +3263,14 @@ func (state *EvalState) evaluateItems(objects []MShellParseItem, stack *MShellSt } case *MShellAsCast: // Static-only: `as` is a checker hint; no runtime work. + case *MShellTryAsCast: + result := state.processTryAsCast(t, stack) + if result.ShouldPassResultUpStack() { + return result + } case *MShellTypeDecl: - // Static-only: type declarations have no runtime effect by design. + // Registers the declaration for `tryAs`; otherwise static-only. + state.registerTypeDecl(t) default: return state.FailWithMessage(fmt.Sprintf("We haven't implemented the type '%T' yet.\n", t)) } diff --git a/mshell/Lexer.go b/mshell/Lexer.go index 7a641f4b..f142e020 100644 --- a/mshell/Lexer.go +++ b/mshell/Lexer.go @@ -109,6 +109,7 @@ const ( TRY FAIL_KEYWORD PURE + TRYAS ) func (t TokenType) String() string { @@ -283,6 +284,8 @@ func (t TokenType) String() string { return "AS" case TYPE: return "TYPE" + case TRYAS: + return "TRYAS" case TRY: return "TRY" case FAIL_KEYWORD: @@ -621,6 +624,9 @@ func (l *Lexer) literalOrKeywordType() TokenType { case 'u': return l.checkKeyword(3, "e", TRUE) case 'y': + if l.curLen() > 3 { + return l.checkKeyword(3, "As", TRYAS) + } return l.checkKeyword(3, "", TRY) } } diff --git a/mshell/Lexer_test.go b/mshell/Lexer_test.go index 4b991541..68bb0c31 100644 --- a/mshell/Lexer_test.go +++ b/mshell/Lexer_test.go @@ -13,6 +13,7 @@ func TestTypeCheckerKeywords(t *testing.T) { {"as", AS}, {"type", TYPE}, {"try", TRY}, + {"tryAs", TRYAS}, {"fail", FAIL_KEYWORD}, {"pure", PURE}, // Make sure neighbors still tokenize as before. @@ -23,6 +24,8 @@ func TestTypeCheckerKeywords(t *testing.T) { {"types", LITERAL}, {"asx", LITERAL}, {"trying", LITERAL}, + {"tryAsX", LITERAL}, + {"tryas", LITERAL}, {"failed", LITERAL}, {"purest", LITERAL}, } diff --git a/mshell/Parser.go b/mshell/Parser.go index 70ae5c02..2824e048 100644 --- a/mshell/Parser.go +++ b/mshell/Parser.go @@ -1102,6 +1102,8 @@ func (parser *MShellParser) ParseItem() (MShellParseItem, error) { return parser.ParsePrefixQuote() case AS: return parser.ParseAsCast() + case TRYAS: + return parser.ParseTryAsCast() default: return parser.ParseSimple(), nil } diff --git a/mshell/TypeCast.go b/mshell/TypeCast.go index dd8d1609..8e6b975c 100644 --- a/mshell/TypeCast.go +++ b/mshell/TypeCast.go @@ -151,6 +151,37 @@ func (c *Checker) Cast(target TypeId, callSite Token) { c.stack.Push(target) } +// TryCast implements ` tryAs `. It pops the top of the +// stack and pushes Maybe[target]. The same compatibility rule as Cast +// applies, but for the opposite reason: a source that could never be the +// target makes the runtime check always-none, which is almost certainly +// a bug, so it is reported. The runtime narrowing itself (wide union in, +// precise type out wrapped in Maybe) is exactly the case Cast's generous +// direction already blesses. +func (c *Checker) TryCast(target TypeId, callSite Token) { + if c.stack.Len() == 0 { + c.errors = append(c.errors, TypeError{ + Kind: TErrStackUnderflow, + Pos: callSite, + Hint: "tryAs", + }) + c.stack.Push(c.arena.MakeMaybe(target)) + return + } + top := c.stack.items[len(c.stack.items)-1] + c.stack.items = c.stack.items[:len(c.stack.items)-1] + + if !c.castOk(top, target) { + c.errors = append(c.errors, TypeError{ + Kind: TErrInvalidCast, + Pos: callSite, + Expected: target, + Actual: top, + }) + } + c.stack.Push(c.arena.MakeMaybe(target)) +} + // castOk reports whether `src` can be cast to `dst`. // // Trials, in order (each isolated by a substitution checkpoint): diff --git a/mshell/TypeCheckProgram.go b/mshell/TypeCheckProgram.go index fe2c4925..aa5b8e87 100644 --- a/mshell/TypeCheckProgram.go +++ b/mshell/TypeCheckProgram.go @@ -482,6 +482,13 @@ func (c *Checker) checkParseItem(item MShellParseItem) { } return + case *MShellTryAsCast: + target := c.resolveTypeExpr(it.Target, nil) + if target != TidNothing { + c.TryCast(target, it.TryAsToken) + } + return + case Token: c.checkOne(it) return diff --git a/mshell/TypeExpr.go b/mshell/TypeExpr.go index f4044858..9dd7ccdb 100644 --- a/mshell/TypeExpr.go +++ b/mshell/TypeExpr.go @@ -70,6 +70,18 @@ func (parser *MShellParser) skipToShapeSync() { // Node types --------------------------------------------------------------- +// TypeExpression is the interface every type-expression node must satisfy. +// The parser productions below return it, so a new node kind cannot be +// introduced without implementing runtime validation — the program will +// not compile. validateObj is the runtime half of `tryAs`: it reports +// whether a value conforms to the type. Its error return is reserved for +// malformed casts (an unknown type name), which fail the script rather +// than producing a `none`. +type TypeExpression interface { + MShellParseItem + validateObj(state *EvalState, obj MShellObject) (bool, error) +} + // TypePrim is a primitive type keyword (int, float, bool, str). type TypePrim struct { Tok Token @@ -87,7 +99,7 @@ func (a *TypePrim) DebugString() string { return a.Tok.Lexeme } type TypeNamed struct { Tok Token Name string - Args []MShellParseItem // populated only for `Maybe[T]`-style application + Args []TypeExpression // populated only for `Maybe[T]`-style application } func (a *TypeNamed) GetStartToken() Token { return a.Tok } @@ -116,7 +128,7 @@ func (a *TypeNamed) DebugString() string { // TypeListExpr is a homogeneous list type `[T]`. type TypeListExpr struct { OpenTok Token - Elem MShellParseItem + Elem TypeExpression } func (a *TypeListExpr) GetStartToken() Token { return a.OpenTok } @@ -127,8 +139,8 @@ func (a *TypeListExpr) DebugString() string { return "[" + a.Elem.DebugString() // TypeDictExpr is a single key:value pair dict type `{K: V}`. type TypeDictExpr struct { OpenTok Token - Key MShellParseItem - Value MShellParseItem + Key TypeExpression + Value TypeExpression } func (a *TypeDictExpr) GetStartToken() Token { return a.OpenTok } @@ -144,7 +156,7 @@ func (a *TypeDictExpr) DebugString() string { type TypeShapeField struct { Tok Token Name string - Type MShellParseItem + Type TypeExpression Optional bool } @@ -156,7 +168,7 @@ type TypeShapeField struct { type TypeShapeExpr struct { OpenTok Token Fields []TypeShapeField - Wildcard MShellParseItem + Wildcard TypeExpression } func (a *TypeShapeExpr) GetStartToken() Token { return a.OpenTok } @@ -218,7 +230,7 @@ func (a *TypeQuoteExpr) DebugString() string { // TypeUnionExpr is a union `A | B | C`. type TypeUnionExpr struct { StartTok Token - Arms []MShellParseItem + Arms []TypeExpression } func (a *TypeUnionExpr) GetStartToken() Token { return a.StartTok } @@ -255,14 +267,14 @@ func (a *TypeUnionExpr) DebugString() string { // parseTypeExpr is the top-level entry point. It parses a union (which // degrades to a single term when there's no `|`). -func (parser *MShellParser) parseTypeExpr() (MShellParseItem, []TypeError) { +func (parser *MShellParser) parseTypeExpr() (TypeExpression, []TypeError) { var errs []TypeError first := parser.parseTypePrimary(&errs) if parser.curr.Type != PIPE { return first, errs } start := first.GetStartToken() - arms := []MShellParseItem{first} + arms := []TypeExpression{first} for parser.curr.Type == PIPE { parser.NextToken() // consume | arms = append(arms, parser.parseTypePrimary(&errs)) @@ -270,7 +282,7 @@ func (parser *MShellParser) parseTypeExpr() (MShellParseItem, []TypeError) { return &TypeUnionExpr{StartTok: start, Arms: arms}, errs } -func (parser *MShellParser) parseTypePrimary(errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseTypePrimary(errs *[]TypeError) TypeExpression { tok := parser.curr switch tok.Type { case TYPEINT: @@ -301,7 +313,7 @@ func (parser *MShellParser) parseTypePrimary(errs *[]TypeError) MShellParseItem return &TypePrim{Tok: tok, Tid: TidNothing} } -func (parser *MShellParser) parseTypeList(errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseTypeList(errs *[]TypeError) TypeExpression { open := parser.curr parser.NextToken() // consume [ elem, subErrs := parser.parseTypeExpr() @@ -329,7 +341,7 @@ func (parser *MShellParser) parseTypeList(errs *[]TypeError) MShellParseItem { // Disambiguation is single-token at the top of the body, with one // extra peek inside the LITERAL branch (to tell a shape field name // from a named type used as the dict key). -func (parser *MShellParser) parseTypeDictOrShape(errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseTypeDictOrShape(errs *[]TypeError) TypeExpression { open := parser.curr parser.NextToken() // consume { if parser.curr.Type == RIGHT_CURLY { @@ -389,7 +401,7 @@ func (parser *MShellParser) parseTypeDictOrShape(errs *[]TypeError) MShellParseI // accepted only if it is literally `str` (for parity with prior sig // syntax); other key types are rejected with TErrTypeParse so the // type system can't pretend to support {int: V} / {path: V} etc. -func (parser *MShellParser) finishDictOrBareType(open Token, first MShellParseItem, errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) finishDictOrBareType(open Token, first TypeExpression, errs *[]TypeError) TypeExpression { if parser.curr.Type == RIGHT_CURLY { // Bare-type sugar: `{T}` -> wildcard dict keyed by str. parser.NextToken() @@ -432,7 +444,7 @@ func (parser *MShellParser) finishDictOrBareType(open Token, first MShellParseIt // parseWildcardDictBody parses `*: T` (with optional trailing comma) // and the closing `}`. The `*` is the current token on entry. -func (parser *MShellParser) parseWildcardDictBody(open Token, errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseWildcardDictBody(open Token, errs *[]TypeError) TypeExpression { parser.NextToken() // consume * if parser.curr.Type != COLON { *errs = append(*errs, TypeError{Kind: TErrTypeParse, Pos: parser.curr, Hint: "expected ':' after '*', got " + tokDesc(parser.curr)}) @@ -454,7 +466,7 @@ func (parser *MShellParser) parseWildcardDictBody(open Token, errs *[]TypeError) // parseTypeShapeBody handles the case where the first key inside `{` // is a quoted string. continueTypeShapeBody does the heavy lifting. -func (parser *MShellParser) parseTypeShapeBody(open Token, errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseTypeShapeBody(open Token, errs *[]TypeError) TypeExpression { shape := &TypeShapeExpr{OpenTok: open} parser.continueTypeShapeBody(shape, errs) return shape @@ -550,7 +562,7 @@ func (parser *MShellParser) continueTypeShapeBody(shape *TypeShapeExpr, errs *[] // synthStrKey returns a synthetic `str` TypePrim positioned at the // `{` opener — used as the key of bare-type sugar `{T}` and pure // wildcard `{*: T}`, both of which resolve to `Dict[str, T]`. -func synthStrKey(open Token) MShellParseItem { +func synthStrKey(open Token) TypeExpression { tok := open tok.Type = STR tok.Lexeme = "str" @@ -572,7 +584,7 @@ func isStrKeyExpr(item MShellParseItem) bool { return false } -func (parser *MShellParser) parseTypeQuote(errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseTypeQuote(errs *[]TypeError) TypeExpression { open := parser.curr parser.NextToken() // consume ( var inputs, outputs []MShellParseItem @@ -599,7 +611,7 @@ func (parser *MShellParser) parseTypeQuote(errs *[]TypeError) MShellParseItem { return &TypeQuoteExpr{OpenTok: open, Inputs: inputs, Outputs: outputs} } -func (parser *MShellParser) parseTypeNamed(errs *[]TypeError) MShellParseItem { +func (parser *MShellParser) parseTypeNamed(errs *[]TypeError) TypeExpression { tok := parser.curr parser.NextToken() node := &TypeNamed{Tok: tok, Name: tok.Lexeme} @@ -622,7 +634,7 @@ func (parser *MShellParser) applyMaybeArgs(node *TypeNamed, errs *[]TypeError) { } else { parser.NextToken() } - node.Args = []MShellParseItem{inner} + node.Args = []TypeExpression{inner} } // isPrimitiveLiteralType reports whether a LITERAL lexeme names a built-in @@ -715,9 +727,10 @@ func (c *Checker) resolveTypeExpr(node MShellParseItem, ctx *typeResolveCtx) Typ } return c.arena.MakeUnion(arms, NameNone) case *TypeNamed: + if bt, ok := builtinNamedTypes[n.Name]; ok { + return bt.resolve(c) + } switch n.Name { - case "bytes": - return TidBytes case "none": // `none` is the empty constructor of Maybe (like Haskell's // Nothing), not a type. Reject it in type position with a @@ -728,18 +741,6 @@ func (c *Checker) resolveTypeExpr(node MShellParseItem, ctx *typeResolveCtx) Typ Hint: "'none' is not a type; it is the empty constructor of Maybe. Use 'Maybe[T]' for an optional value, or 'null' for the JSON null type", }) return TidNothing - case "null": - return TidNull - case "path": - return TidPath - case "datetime": - return TidDateTime - case "Grid": - return c.arena.MakeGrid(0) - case "GridView": - return c.arena.MakeGridView(0) - case "GridRow": - return c.arena.MakeGridRow(0) case "Maybe": if len(n.Args) != 1 { return TidNothing @@ -794,3 +795,180 @@ func (c *Checker) ResolveDefSig(inputs, outputs []MShellParseItem) QuoteSig { } return QuoteSig{Inputs: ins, Outputs: outs, Generics: gens} } + +// Built-in named types ------------------------------------------------------ + +// builtinNamedType couples the two halves of a built-in named type: how +// the checker resolves the name to a TypeId, and how the `tryAs` runtime +// checks a value against it. Both resolveTypeExpr and +// (*TypeNamed).validateObj consult this single table, so a new built-in +// type cannot be half-added — registering it here wires up the checker +// and the runtime together, and a name missing from the table does not +// exist statically either. +// +// `Maybe` (parametric) and `none` (a constructor, not a type) are handled +// specially by both consumers and do not belong in the table. +type builtinNamedType struct { + resolve func(c *Checker) TypeId + runtimeCheck func(obj MShellObject) bool +} + +var builtinNamedTypes = map[string]builtinNamedType{ + "bytes": { + resolve: func(*Checker) TypeId { return TidBytes }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(MShellBinary); return ok }, + }, + "null": { + resolve: func(*Checker) TypeId { return TidNull }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(MShellNull); return ok }, + }, + "path": { + resolve: func(*Checker) TypeId { return TidPath }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(MShellPath); return ok }, + }, + "datetime": { + resolve: func(*Checker) TypeId { return TidDateTime }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(*MShellDateTime); return ok }, + }, + "Grid": { + resolve: func(c *Checker) TypeId { return c.arena.MakeGrid(0) }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(*MShellGrid); return ok }, + }, + "GridView": { + resolve: func(c *Checker) TypeId { return c.arena.MakeGridView(0) }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(*MShellGridView); return ok }, + }, + "GridRow": { + resolve: func(c *Checker) TypeId { return c.arena.MakeGridRow(0) }, + runtimeCheck: func(obj MShellObject) bool { _, ok := obj.(*MShellGridRow); return ok }, + }, +} + +// Runtime validation (`tryAs`) ---------------------------------------------- +// +// Each node's validateObj checks a runtime value structurally against the +// type expression; the TypeExpression interface forces every node kind to +// implement one. Semantics mirror the match block's type arms where both +// exist: `str` matches MShellString only, `int` matches MShellInt (a float +// with an integral value does not conform), and quotation types are +// checked by kind only — signatures are not verified at runtime. Shape +// validation allows extra keys (width subtyping), requires all +// non-optional fields, and validates optional fields only when present. + +func (a *TypePrim) validateObj(state *EvalState, obj MShellObject) (bool, error) { + switch a.Tid { + case TidInt: + _, ok := obj.(MShellInt) + return ok, nil + case TidFloat: + _, ok := obj.(MShellFloat) + return ok, nil + case TidBool: + _, ok := obj.(MShellBool) + return ok, nil + case TidStr: + _, ok := obj.(MShellString) + return ok, nil + } + // parseTypePrimary only constructs TypePrim with the four Tids above; + // its error fallback (TidNothing) records a parse error that aborts + // the cast before evaluation, so this is unreachable by construction. + return false, fmt.Errorf("tryAs cannot check the type '%s'", a.Tok.Lexeme) +} + +func (a *TypeNamed) validateObj(state *EvalState, obj MShellObject) (bool, error) { + if bt, ok := builtinNamedTypes[a.Name]; ok { + return bt.runtimeCheck(obj), nil + } + if a.Name == "Maybe" { + var inner MShellObject + switch m := obj.(type) { + case Maybe: + inner = m.obj + case *Maybe: + inner = m.obj + default: + return false, nil + } + if inner == nil { + // `none` conforms to Maybe[T] for every T. + return true, nil + } + if len(a.Args) != 1 { + return false, fmt.Errorf("Maybe requires a type argument: Maybe[T]") + } + return a.Args[0].validateObj(state, inner) + } + body, ok := state.typeDefs[a.Name] + if !ok { + return false, fmt.Errorf("unknown type '%s' in tryAs; a `type %s = ...` declaration must run before the cast", a.Name, a.Name) + } + return body.validateObj(state, obj) +} + +func (a *TypeListExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { + list, ok := obj.(*MShellList) + if !ok { + return false, nil + } + for _, item := range list.Items { + itemOk, err := a.Elem.validateObj(state, item) + if err != nil || !itemOk { + return itemOk, err + } + } + return true, nil +} + +func (a *TypeDictExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { + dict, ok := obj.(*MShellDict) + if !ok { + return false, nil + } + for _, val := range dict.Items { + valOk, err := a.Value.validateObj(state, val) + if err != nil || !valOk { + return valOk, err + } + } + return true, nil +} + +func (a *TypeShapeExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { + dict, ok := obj.(*MShellDict) + if !ok { + return false, nil + } + for _, f := range a.Fields { + val, present := dict.Items[f.Name] + if !present { + if f.Optional { + continue + } + return false, nil + } + fieldOk, err := f.Type.validateObj(state, val) + if err != nil || !fieldOk { + return fieldOk, err + } + } + return true, nil +} + +func (a *TypeQuoteExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { + _, ok := obj.(*MShellQuotation) + return ok, nil +} + +func (a *TypeUnionExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { + for _, arm := range a.Arms { + armOk, err := arm.validateObj(state, obj) + if err != nil { + return false, err + } + if armOk { + return true, nil + } + } + return false, nil +} diff --git a/mshell/TypeParseIntegration.go b/mshell/TypeParseIntegration.go index 20979c26..d8d1c492 100644 --- a/mshell/TypeParseIntegration.go +++ b/mshell/TypeParseIntegration.go @@ -2,14 +2,17 @@ package main // Parse-tree nodes that introduce a type expression at the program level: // -// - MShellTypeDecl: `type Name = ` top-level declaration. -// - MShellAsCast: ` as ` postfix cast. +// - MShellTypeDecl: `type Name = ` top-level declaration. +// - MShellAsCast: ` as ` postfix cast. +// - MShellTryAsCast: ` tryAs ` checked cast. // -// Both store the parsed-but-unresolved type AST. Resolution to TypeIds +// All store the parsed-but-unresolved type AST. Resolution to TypeIds // happens when the checker walks the parse tree, so forward references // to user-declared types work in declaration order. At evaluation time -// both nodes are no-ops — `as` is purely static and `type` declarations -// have no runtime effect by design. +// `as` is a no-op (purely static); `type` declarations register their +// body in the evaluator's type environment so `tryAs` can resolve named +// types; `tryAs` validates the top of the stack structurally and pushes +// a Maybe. import ( "fmt" @@ -21,7 +24,7 @@ type MShellTypeDecl struct { Name string NameToken Token StartTok Token // the TYPE keyword - Body MShellParseItem + Body TypeExpression } func (d *MShellTypeDecl) ToJson() string { @@ -38,7 +41,7 @@ func (d *MShellTypeDecl) GetEndToken() Token { return d.NameToken } // MShellAsCast is a ` as ` postfix cast. type MShellAsCast struct { AsToken Token - Target MShellParseItem + Target TypeExpression } func (c *MShellAsCast) ToJson() string { @@ -52,6 +55,27 @@ func (c *MShellAsCast) DebugString() string { func (c *MShellAsCast) GetStartToken() Token { return c.AsToken } func (c *MShellAsCast) GetEndToken() Token { return c.AsToken } +// MShellTryAsCast is a ` tryAs ` checked cast. Unlike +// `as`, it does runtime work: the evaluator validates the value against +// the type expression structurally and pushes a Maybe — `just value` on +// success, `none` on mismatch. Statically it consumes the value and +// produces Maybe[target]. +type MShellTryAsCast struct { + TryAsToken Token + Target TypeExpression +} + +func (c *MShellTryAsCast) ToJson() string { + return "{\"kind\": \"tryAsCast\"}" +} + +func (c *MShellTryAsCast) DebugString() string { + return "tryAs " +} + +func (c *MShellTryAsCast) GetStartToken() Token { return c.TryAsToken } +func (c *MShellTryAsCast) GetEndToken() Token { return c.TryAsToken } + // ParseTypeDecl handles a top-level `type Name = `. The TYPE // keyword is the current token on entry; on return, parser.curr is past // the type expression. @@ -93,6 +117,18 @@ func (parser *MShellParser) ParseAsCast() (*MShellAsCast, error) { return &MShellAsCast{AsToken: asTok, Target: target}, nil } +// ParseTryAsCast handles a postfix `tryAs `. The TRYAS keyword +// is the current token on entry. +func (parser *MShellParser) ParseTryAsCast() (*MShellTryAsCast, error) { + tryAsTok := parser.curr + parser.NextToken() // consume TRYAS + target, errs := parser.parseTypeExpr() + if len(errs) > 0 { + return nil, fmt.Errorf("'tryAs' target: %s", joinTypeErrs(errs)) + } + return &MShellTryAsCast{TryAsToken: tryAsTok, Target: target}, nil +} + func joinTypeErrs(errs []TypeError) string { var sb strings.Builder wrote := 0 diff --git a/tests/success/tryas.msh b/tests/success/tryas.msh new file mode 100644 index 00000000..ea50a394 --- /dev/null +++ b/tests/success/tryas.msh @@ -0,0 +1,68 @@ +# `tryAs` is the checked cast: it validates the value against the type +# at runtime and pushes a Maybe — `just value` on success, `none` on +# mismatch. Unlike `as` (a static-only hint), it is the tool for data +# crossing a trust boundary like parseJson output. + +type Manifest = {packages: [{name: str}]} +type Cell = int | float | str | bool | null +type Config = {url: str, timeout?: float} + +# Conforming JSON narrows to the precise shape; getters need no matching. +"{\"packages\": [{\"name\": \"a\"}, {\"name\": \"b\"}]}" parseJson tryAs Manifest match + just m : @m :packages? (:name?) map ", " join wl, + none : "no match" wl, +end + +# Non-conforming JSON (name is a number) is none. +"{\"packages\": [{\"name\": 42}]}" parseJson tryAs Manifest match + just _ : "matched" wl, + none : "rejected" wl, +end + +# The existing `?` unwrap composes for the die-loud form. +"{\"packages\": []}" parseJson tryAs Manifest ? :packages? len wl + +# Union arms: any arm conforms. parseJson numbers are floats. +"[1, 2.5, \"x\", true, null]" parseJson tryAs [Cell] match + just _ : "cells ok" wl, + none : "cells bad" wl, +end + +# Optional shape field may be absent... +"{\"url\": \"http://x\"}" parseJson tryAs Config match + just c : @c :url? wl, + none : "cfg1 bad" wl, +end + +# ...but when present it must have the declared type. +"{\"url\": \"http://x\", \"timeout\": \"soon\"}" parseJson tryAs Config match + just _ : "cfg2 matched" wl, + none : "cfg2 rejected" wl, +end + +# Extra keys are allowed, matching shape width subtyping. +"{\"url\": \"x\", \"other\": 1}" parseJson tryAs Config match + just _ : "cfg3 ok" wl, + none : "cfg3 bad" wl, +end + +# Homogeneous dictionary values are each validated. +"{\"a\": 1, \"b\": 2}" parseJson tryAs {str: float} match + just _ : "counts ok" wl, + none : "counts bad" wl, +end + +# Kind mismatch on boundary data is a none, not an error. +"\"x\"" parseJson tryAs [float] match + just _ : "?!" wl, + none : "str is not list" wl, +end + +# Non-JSON runtime types validate by kind. +`/tmp` tryAs path match just _ : "path ok" wl, none : "path bad" wl, end +2024-01-15 tryAs datetime match just _ : "dt ok" wl, none : "dt bad" wl, end +(1 +) tryAs (int -- int) match just _ : "quote ok" wl, none : "quote bad" wl, end + +# A none conforms to Maybe[T] for every T; a just validates its inner value. +1 just tryAs Maybe[int] match just _ : "maybe ok" wl, none : "maybe bad" wl, end +none tryAs Maybe[int] match just _ : "none is maybe" wl, none : "hm" wl, end diff --git a/tests/success/tryas.msh.stdout b/tests/success/tryas.msh.stdout new file mode 100644 index 00000000..1f65f8ee --- /dev/null +++ b/tests/success/tryas.msh.stdout @@ -0,0 +1,14 @@ +a, b +rejected +0 +cells ok +http://x +cfg2 rejected +cfg3 ok +counts ok +str is not list +path ok +dt ok +quote ok +maybe ok +none is maybe diff --git a/tests/typecheck_fail/tryas_statically_impossible.msh b/tests/typecheck_fail/tryas_statically_impossible.msh new file mode 100644 index 00000000..47aace2c --- /dev/null +++ b/tests/typecheck_fail/tryas_statically_impossible.msh @@ -0,0 +1,6 @@ +# A `tryAs` whose source type can never be the target is always none at +# runtime, which is almost certainly a bug — the checker rejects it. +5 tryAs str match + just _ : "yes" wl, + none : "no" wl, +end From ba9937b4cf137f4e576876db8f7edb651f9a8f68 Mon Sep 17 00:00:00 2001 From: Mitchell Paulus Date: Fri, 10 Jul 2026 07:06:47 -0500 Subject: [PATCH 2/2] Add depth limits --- CHANGELOG.md | 3 + ai/tryas_review.md | 119 +++++++++++++++++++++ doc/mshell.md | 1 + doc/type_system.inc.html | 1 + mshell/Evaluator.go | 2 +- mshell/TypeExpr.go | 74 ++++++++++--- tests/fail/tryas_cyclic_type.msh | 4 + tests/fail/tryas_cyclic_type.msh.stderr | 1 + tests/fail/tryas_cyclic_value.msh | 5 + tests/fail/tryas_cyclic_value.msh.stderr | 1 + tests/fail/tryas_depth_exceeded.msh | 6 ++ tests/fail/tryas_depth_exceeded.msh.stderr | 1 + tests/success/tryas_depth.msh | 7 ++ tests/success/tryas_depth.msh.stdout | 1 + 14 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 ai/tryas_review.md create mode 100644 tests/fail/tryas_cyclic_type.msh create mode 100644 tests/fail/tryas_cyclic_type.msh.stderr create mode 100644 tests/fail/tryas_cyclic_value.msh create mode 100644 tests/fail/tryas_cyclic_value.msh.stderr create mode 100644 tests/fail/tryas_depth_exceeded.msh create mode 100644 tests/fail/tryas_depth_exceeded.msh.stderr create mode 100644 tests/success/tryas_depth.msh create mode 100644 tests/success/tryas_depth.msh.stdout diff --git a/CHANGELOG.md b/CHANGELOG.md index a4aa83f6..3337866a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 existing `?` unwrap for a die-loud form, e.g. `"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map`. A `tryAs` whose source type could never be the target is a type error. + Validation depth is limited to 1024 nested levels; exceeding it (a cyclic + value, a cyclic `type` declaration, or absurdly deep data) fails the script + with a clear error rather than producing `none`. - CLI completions for `cargo`: subcommands (including installed third-party ones via `cargo --list`), per-subcommand options, and dynamic values for diff --git a/ai/tryas_review.md b/ai/tryas_review.md new file mode 100644 index 00000000..2582c81b --- /dev/null +++ b/ai/tryas_review.md @@ -0,0 +1,119 @@ +# tryAs review — outstanding items + +Critical review of the `try-as` branch (`7dce9f5 Implement tryAs`), 2026-07-09. +The unbounded-recursion crashes found in this review are already **fixed** on the +branch (depth budget of 1024 in `validateObj`, see `maxTryAsValidateDepth` in +`mshell/TypeExpr.go`, with regression tests in `tests/fail/tryas_cyclic_type.msh`, +`tests/fail/tryas_cyclic_value.msh`, `tests/fail/tryas_depth_exceeded.msh`, and +`tests/success/tryas_depth.msh`). Everything below is still open and needs a +decision. + +## 1. The recursive-type contradiction (design decision needed) + +The runtime and the checker disagree about whether recursive named types exist. + +- Runtime: `tryAs Name` resolves names late through `state.typeDefs`, so + `type Tree = {value: int, kids?: [Tree]}` validates finite values correctly. + Verified: `type A = [B]` / `type B = [A]` works at runtime. +- Checker: `CheckProgram` pre-pass 1 resolves each `type` body in file order + *before* declaring the name, so any self- or forward-reference is + "unknown type" — recursive types are statically unrepresentable. + +Consequence: recursive types (the natural way to type JSON trees, which is the +flagship `tryAs` use case) work only when you *don't* pass `--check-types`. +The two halves should agree. Options: + +- **(a)** Register all type names before resolving bodies so the checker admits + recursion. The arena/TypeId side needs a story for the cycle (a `TidNamed` + indirection or iterative resolution). Runtime is already safe now that the + depth budget exists. This is the option that makes the flagship use case work. +- **(b)** Make the runtime reject what the checker rejects: resolve/validate the + type AST eagerly at `type` declaration time and fail on unknown names. Loses + recursive types entirely. + +## 2. JSON integer footgun passes the checker silently (high) + +`parseJson` produces a `float` for every JSON number, so any `int` inside a +`tryAs` target is **always-none** against JSON data — and `--check-types` says +nothing. Verified: + +```mshell +"[1, 2]" parseJson tryAs [int] # always none; checker silent +``` + +The docs warn about this (good), but the branch already added a checker error +for statically-impossible casts (`5 tryAs str`); this is the same bug class and +the one users will actually hit, since `{count: int}` is what everyone writes +first. Options: + +- Checker warning when the cast source is the JSON output union and the target + contains `int` anywhere. +- Accept integral floats for `int` in `validateObj` — rejected in the branch's + own comments because validation deliberately mirrors `match` type-arm + semantics; changing it here without changing `match` creates a new + inconsistency. +- Make `parseJson` produce `int` for integral numbers — biggest change, fixes + the root cause, affects existing scripts that assume float. + +## 3. Wildcard shapes validate nothing at runtime (medium) + +`TypeShapeExpr.validateObj` never reads `a.Wildcard`. Verified inconsistency: + +```mshell +{"a": 1, "b": "x"} tryAs {a: float, *: float} # just — wildcard ignored +{"a": 1, "b": "x"} tryAs {*: float} # none — dict form checks values +``` + +For static `as` dropping the wildcard was harmless (width subtyping already +allows extra keys — comment at the `TypeShapeExpr` declaration in +`mshell/TypeExpr.go`), but `tryAs` makes runtime claims: a user who writes +`*: float` is asking for enforcement they silently don't get. Options: + +- Validate non-field keys against the wildcard type in + `TypeShapeExpr.validateObj` (my preference: it's what the syntax says). +- Reject wildcard shapes as `tryAs` targets with a clear error (fail closed). + +Either is better than the current silent half-validation. + +## 4. Quotation targets are half-open (medium) + +`tryAs (int -- int)` checks only that the value is a quotation +(`TypeQuoteExpr.validateObj`) — signatures are not verified, so +`("hi" wl) tryAs (int -- int)` produces `just`, and the checker afterward +trusts `Maybe[(int -- int)]` for a value with a different signature. That is a +static-soundness hole shaped like a checked cast. It is documented, but +consider failing closed instead: reject quote types in `tryAs` targets with an +error ("quotation signatures cannot be verified at runtime"), rather than +issuing a certificate the runtime can't back. If kind-only checking is kept, +it stays a documented sharp edge. + +## 5. Union arm errors are value-dependent (low) + +`1 tryAs int | Bogus` never reports the unknown type `Bogus` (first arm +matches, short-circuit); a value that matches no arm hits the error instead. +So whether a malformed type expression is diagnosed depends on the data. A +one-time eager walk of the type AST at cast time (validating all names/arity) +would make the errors deterministic. Cheap to do now; confusing bug reports +later if skipped. + +## 6. Notes, no action required + +- `validateObj`'s `case Maybe:` (value, non-pointer) branch is dead code — the + codebase only ever pushes `*Maybe`. Harmless. +- The `just` result retains the original mutable object; a post-validation + mutation through an alias (`append` on a still-reachable inner list) can + invalidate the certified type. Inherent to the language's aliasing + semantics, not specific to this branch; maybe worth one sentence in the + docs next to "parse, don't validate". +- Lexer edges verified good: `tryas`, `tryAsX`, `tryA`, `trying` all stay + LITERAL; bare `tryAs` and `Maybe`-without-argument produce clean parse + errors. +- `builtinNamedTypes` table (single source for checker resolution + runtime + check) and the `TypeExpression` interface forcing `validateObj` on every + node kind are both good structure — keep that pattern for future node kinds. +- Test-coverage suggestions once decisions above land: wildcard-shape + behavior (whichever way it goes), a quotation-target case, nested + `Maybe[Maybe[T]]`, and a `tryAs` inside a quotation in `tests/success/` + (works today — verified `(tryAs int ...) map` — but only top-level uses are + covered, and quotations dispatch through the separate `evaluateItems` path + in `mshell/Evaluator.go`). diff --git a/doc/mshell.md b/doc/mshell.md index f39ddc1a..d1639c77 100644 --- a/doc/mshell.md +++ b/doc/mshell.md @@ -590,6 +590,7 @@ Shape validation allows extra keys, requires all non-optional fields, and valida A `none` conforms to `Maybe[T]` for every `T`; quotation types are checked by kind only (signatures are not verified at runtime). Named types must be declared before the cast runs. Note that `parseJson` produces a `float` for every JSON number, so validate JSON numbers as `float`, not `int`. +Validation depth is limited to 1024 nested levels; exceeding it (a cyclic value, a cyclic `type` declaration, or absurdly deep data) fails the script with a clear error rather than producing `none`. Dictionary types are split into homogeneous dictionaries and shapes. A homogeneous dictionary is for dynamic keys where every value has the same type. diff --git a/doc/type_system.inc.html b/doc/type_system.inc.html index 100c430a..56fcf5fe 100644 --- a/doc/type_system.inc.html +++ b/doc/type_system.inc.html @@ -189,6 +189,7 @@

Checked casts with tryAs Dictionaries § Back to top

diff --git a/mshell/Evaluator.go b/mshell/Evaluator.go index e5f16aee..d1f134a6 100644 --- a/mshell/Evaluator.go +++ b/mshell/Evaluator.go @@ -947,7 +947,7 @@ func (state *EvalState) processTryAsCast(cast *MShellTryAsCast, stack *MShellSta if err != nil { return state.FailWithMessage(fmt.Sprintf("%d:%d: 'tryAs' requires a value on the stack.\n", cast.TryAsToken.Line, cast.TryAsToken.Column)) } - conforms, verr := cast.Target.validateObj(state, obj) + conforms, verr := cast.Target.validateObj(state, obj, maxTryAsValidateDepth) if verr != nil { return state.FailWithMessage(fmt.Sprintf("%d:%d: %s\n", cast.TryAsToken.Line, cast.TryAsToken.Column, verr.Error())) } diff --git a/mshell/TypeExpr.go b/mshell/TypeExpr.go index 9dd7ccdb..643af1f1 100644 --- a/mshell/TypeExpr.go +++ b/mshell/TypeExpr.go @@ -75,11 +75,32 @@ func (parser *MShellParser) skipToShapeSync() { // introduced without implementing runtime validation — the program will // not compile. validateObj is the runtime half of `tryAs`: it reports // whether a value conforms to the type. Its error return is reserved for -// malformed casts (an unknown type name), which fail the script rather -// than producing a `none`. +// malformed casts (an unknown type name, or a blown depth budget), which +// fail the script rather than producing a `none`. +// +// depth is the remaining recursion budget. Every node checks it on entry +// and passes depth-1 to child validations, so the walk is bounded even +// against a cyclic value (a list appended to itself) or a runtime type +// environment with a cyclic declaration (`type Loop = Loop`), both of +// which would otherwise overflow the Go stack and kill the process. +// Entry points start at maxTryAsValidateDepth. type TypeExpression interface { MShellParseItem - validateObj(state *EvalState, obj MShellObject) (bool, error) + validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) +} + +// maxTryAsValidateDepth bounds validateObj recursion. Structural nesting, +// named-type expansion, and union dispatch each consume one level, so a +// recursive union type like `type T = [float | T]` spends about four +// levels per level of value nesting — 1024 still admits values nested +// ~250 deep, well past any real boundary data (serde, for comparison, +// caps JSON at 128), while turning cyclic values and cyclic type +// declarations into a loud, catchable script failure instead of a fatal +// stack overflow. +const maxTryAsValidateDepth = 1024 + +func errTryAsDepth() error { + return fmt.Errorf("tryAs validation exceeded the maximum depth of %d; the value or a 'type' declaration is likely cyclic", maxTryAsValidateDepth) } // TypePrim is a primitive type keyword (int, float, bool, str). @@ -855,7 +876,10 @@ var builtinNamedTypes = map[string]builtinNamedType{ // validation allows extra keys (width subtyping), requires all // non-optional fields, and validates optional fields only when present. -func (a *TypePrim) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypePrim) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } switch a.Tid { case TidInt: _, ok := obj.(MShellInt) @@ -876,7 +900,10 @@ func (a *TypePrim) validateObj(state *EvalState, obj MShellObject) (bool, error) return false, fmt.Errorf("tryAs cannot check the type '%s'", a.Tok.Lexeme) } -func (a *TypeNamed) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypeNamed) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } if bt, ok := builtinNamedTypes[a.Name]; ok { return bt.runtimeCheck(obj), nil } @@ -897,22 +924,25 @@ func (a *TypeNamed) validateObj(state *EvalState, obj MShellObject) (bool, error if len(a.Args) != 1 { return false, fmt.Errorf("Maybe requires a type argument: Maybe[T]") } - return a.Args[0].validateObj(state, inner) + return a.Args[0].validateObj(state, inner, depth-1) } body, ok := state.typeDefs[a.Name] if !ok { return false, fmt.Errorf("unknown type '%s' in tryAs; a `type %s = ...` declaration must run before the cast", a.Name, a.Name) } - return body.validateObj(state, obj) + return body.validateObj(state, obj, depth-1) } -func (a *TypeListExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypeListExpr) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } list, ok := obj.(*MShellList) if !ok { return false, nil } for _, item := range list.Items { - itemOk, err := a.Elem.validateObj(state, item) + itemOk, err := a.Elem.validateObj(state, item, depth-1) if err != nil || !itemOk { return itemOk, err } @@ -920,13 +950,16 @@ func (a *TypeListExpr) validateObj(state *EvalState, obj MShellObject) (bool, er return true, nil } -func (a *TypeDictExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypeDictExpr) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } dict, ok := obj.(*MShellDict) if !ok { return false, nil } for _, val := range dict.Items { - valOk, err := a.Value.validateObj(state, val) + valOk, err := a.Value.validateObj(state, val, depth-1) if err != nil || !valOk { return valOk, err } @@ -934,7 +967,10 @@ func (a *TypeDictExpr) validateObj(state *EvalState, obj MShellObject) (bool, er return true, nil } -func (a *TypeShapeExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypeShapeExpr) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } dict, ok := obj.(*MShellDict) if !ok { return false, nil @@ -947,7 +983,7 @@ func (a *TypeShapeExpr) validateObj(state *EvalState, obj MShellObject) (bool, e } return false, nil } - fieldOk, err := f.Type.validateObj(state, val) + fieldOk, err := f.Type.validateObj(state, val, depth-1) if err != nil || !fieldOk { return fieldOk, err } @@ -955,14 +991,20 @@ func (a *TypeShapeExpr) validateObj(state *EvalState, obj MShellObject) (bool, e return true, nil } -func (a *TypeQuoteExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypeQuoteExpr) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } _, ok := obj.(*MShellQuotation) return ok, nil } -func (a *TypeUnionExpr) validateObj(state *EvalState, obj MShellObject) (bool, error) { +func (a *TypeUnionExpr) validateObj(state *EvalState, obj MShellObject, depth int) (bool, error) { + if depth <= 0 { + return false, errTryAsDepth() + } for _, arm := range a.Arms { - armOk, err := arm.validateObj(state, obj) + armOk, err := arm.validateObj(state, obj, depth-1) if err != nil { return false, err } diff --git a/tests/fail/tryas_cyclic_type.msh b/tests/fail/tryas_cyclic_type.msh new file mode 100644 index 00000000..069fa279 --- /dev/null +++ b/tests/fail/tryas_cyclic_type.msh @@ -0,0 +1,4 @@ +# A cyclic runtime type declaration must exhaust the tryAs depth +# budget and fail loudly instead of overflowing the Go stack. +type Loop = Loop +1 tryAs Loop diff --git a/tests/fail/tryas_cyclic_type.msh.stderr b/tests/fail/tryas_cyclic_type.msh.stderr new file mode 100644 index 00000000..4e63b8bd --- /dev/null +++ b/tests/fail/tryas_cyclic_type.msh.stderr @@ -0,0 +1 @@ +4:3: tryAs validation exceeded the maximum depth of 1024; the value or a 'type' declaration is likely cyclic diff --git a/tests/fail/tryas_cyclic_value.msh b/tests/fail/tryas_cyclic_value.msh new file mode 100644 index 00000000..c2b03134 --- /dev/null +++ b/tests/fail/tryas_cyclic_value.msh @@ -0,0 +1,5 @@ +# A cyclic value (a list containing itself) reached through a +# recursive type must exhaust the tryAs depth budget and fail +# loudly instead of overflowing the Go stack. +type T = [int | T] +[1] l! @l @l append tryAs T diff --git a/tests/fail/tryas_cyclic_value.msh.stderr b/tests/fail/tryas_cyclic_value.msh.stderr new file mode 100644 index 00000000..362a1aad --- /dev/null +++ b/tests/fail/tryas_cyclic_value.msh.stderr @@ -0,0 +1 @@ +5:21: tryAs validation exceeded the maximum depth of 1024; the value or a 'type' declaration is likely cyclic diff --git a/tests/fail/tryas_depth_exceeded.msh b/tests/fail/tryas_depth_exceeded.msh new file mode 100644 index 00000000..88af3717 --- /dev/null +++ b/tests/fail/tryas_depth_exceeded.msh @@ -0,0 +1,6 @@ +# Exceeding the tryAs validation depth budget must fail the script +# loudly (never a silent none, never a Go stack overflow). +"[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1.5]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]" parseJson tryAs [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[float]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] match + just _ : "?" wl, + none : "?" wl, +end diff --git a/tests/fail/tryas_depth_exceeded.msh.stderr b/tests/fail/tryas_depth_exceeded.msh.stderr new file mode 100644 index 00000000..c90eb912 --- /dev/null +++ b/tests/fail/tryas_depth_exceeded.msh.stderr @@ -0,0 +1 @@ +3:2217: tryAs validation exceeded the maximum depth of 1024; the value or a 'type' declaration is likely cyclic diff --git a/tests/success/tryas_depth.msh b/tests/success/tryas_depth.msh new file mode 100644 index 00000000..9086784a --- /dev/null +++ b/tests/success/tryas_depth.msh @@ -0,0 +1,7 @@ +# The tryAs validation depth budget (1024) must admit legitimately +# deep boundary data: 500 levels of structural nesting is far past +# any real JSON, and must validate as just, not hit the limit. +"[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1.5]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]" parseJson tryAs [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[float]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] match + just _ : "deep ok" wl, + none : "deep none" wl, +end diff --git a/tests/success/tryas_depth.msh.stdout b/tests/success/tryas_depth.msh.stdout new file mode 100644 index 00000000..cd78b827 --- /dev/null +++ b/tests/success/tryas_depth.msh.stdout @@ -0,0 +1 @@ +deep ok