Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4cc77bd
first pass (change outside interface)
ascandone Jun 11, 2026
2e331d8
updated json specs files (claude-generated)
ascandone Jun 11, 2026
8573d13
second pass: better balances/internal balances separation, test utili…
ascandone Jun 12, 2026
538bc72
removed dead code
ascandone Jun 12, 2026
b7a85ff
fix pprint and dedup impl
ascandone Jun 12, 2026
a645069
refactor: extract types into their own modules
ascandone Jun 12, 2026
6f2030c
switch from alias to nominal type
ascandone Jun 12, 2026
b19ba60
removed TODO comment (verified correctness)
ascandone Jun 12, 2026
05b7a74
add comment
ascandone Jun 12, 2026
135270f
minor: fix comments typos
ascandone Jun 12, 2026
7537f3c
add comment
ascandone Jun 12, 2026
3cd4c05
update schemas
ascandone Jun 12, 2026
8d256e9
feat: reject bad specs format balances inputs
ascandone Jun 12, 2026
922b2f2
moved utility
ascandone Jun 12, 2026
479b8b2
reject bad inputs on run
ascandone Jun 12, 2026
042da10
fixes
ascandone Jun 12, 2026
e4d8a5c
fix lint issue
ascandone Jun 12, 2026
c50e3c1
removed unused property in ast
ascandone Jun 12, 2026
52682e7
more precise asset scaling
ascandone Jun 12, 2026
0b23746
refactor: made code more robust
ascandone Jun 12, 2026
171f11a
update test fmt
ascandone Jun 12, 2026
125882b
fix: fix mcp description
ascandone Jun 12, 2026
f14aa09
fix: fix postings pprint
ascandone Jun 12, 2026
6a42661
fix
ascandone Jun 12, 2026
f5a5dea
fix: fix bad input for mcp
ascandone Jun 12, 2026
0584049
fix: export balancerow
ascandone Jun 15, 2026
5183658
fix: update movements assertion
ascandone Jun 15, 2026
b18cbe6
fix labels
ascandone Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions inputs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,31 @@
},
"definitions": {
"Balances": {
"type": "array",
"description": "List of account balances. The (account, asset, color) triple of each entry must be unique within the list.",
"items": { "$ref": "#/definitions/BalanceRow" }
},

"BalanceRow": {
"type": "object",
"description": "Map of account names to asset balances",
"description": "The balance of a given (account, asset, color)",
"additionalProperties": false,
"patternProperties": {
"^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^([A-Z]+(/[0-9]+)?)$": {
"type": "number"
}
}
"required": ["account", "asset", "amount"],
"properties": {
"account": {
"type": "string",
"pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$"
},
"asset": {
"type": "string",
"pattern": "^([A-Z]+(/[0-9]+)?)$"
},
"amount": {
"type": "number"
},
"color": {
"type": "string",
"pattern": "^[A-Z]*$"
}
}
},
Expand Down
1 change: 0 additions & 1 deletion internal/analysis/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,6 @@ func (res *CheckResult) checkSource(source parser.Source) {
case *parser.SourceWithScaling:
res.checkExpression(source.Address, TypeAccount)
res.checkExpression(source.Through, TypeAccount)
res.checkExpression(source.Color, TypeString)

case *parser.SourceInorder:
for _, source := range source.Sources {
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ func run(scriptPath string, opts RunArgs) error {
return fmt.Errorf("failed to parse inputs file '%s' as JSON: %w", inputsPath, err)
}

// Reject a malformed inputs file before running anything: a balance list is a
// map keyed by (account, asset, color), so a repeated key is ambiguous.
if dup, ok := inputs.Balances.FirstDuplicate(); ok {
key := fmt.Sprintf("account=%q asset=%q", dup.Account, dup.Asset)
if dup.Color != "" {
key += fmt.Sprintf(" color=%q", dup.Color)
}
return fmt.Errorf("invalid inputs file '%s': balances must not contain duplicate entries: duplicate entry for %s", inputsPath, key)
}

featureFlags := map[string]struct{}{}
for _, flag := range inputs.FeatureFlags {
featureFlags[flag] = struct{}{}
Expand Down
77 changes: 45 additions & 32 deletions internal/cmd/test_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/formancehq/numscript/internal/interpreter"
"github.com/formancehq/numscript/internal/parser"
"github.com/formancehq/numscript/internal/specs_format"
"github.com/formancehq/numscript/internal/utils"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -114,10 +113,12 @@ func makeSpecsFile(
defaultBalance *big.Int,
) (specs_format.Specs, error) {

store := TestInitStore{
store := &TestInitStore{
DefaultBalance: defaultBalance,
Balances: make(interpreter.Balances),
Meta: make(interpreter.AccountsMetadata),
StaticStore: interpreter.StaticStore{
Balances: interpreter.Balances{},
Meta: make(interpreter.AccountsMetadata),
},
}

res, iErr := interpreter.RunProgram(
Expand Down Expand Up @@ -147,7 +148,7 @@ func makeSpecsFile(
program,
vars,
featureFlags,
&missingFundsErr.Needed,
defaultBalance,
)
}

Expand All @@ -161,7 +162,7 @@ func makeSpecsFile(

specs := specs_format.Specs{
Schema: "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json",
Balances: store.Balances,
Balances: store.StaticStore.Balances,
Vars: vars,
FeatureFlags: featureFlags_,
TestCases: []specs_format.TestCase{
Expand Down Expand Up @@ -200,40 +201,52 @@ func runTestInitCmd(opts testInitArgs) error {

type TestInitStore struct {
DefaultBalance *big.Int
Balances interpreter.Balances
Meta interpreter.AccountsMetadata
StaticStore interpreter.StaticStore
}

func (s TestInitStore) GetBalances(_ context.Context, q interpreter.BalanceQuery) (interpreter.Balances, error) {
outputBalance := interpreter.Balances{}
for queriedAccount, queriedCurrencies := range q {

for _, curr := range queriedCurrencies {
amt := utils.NestedMapGetOrPutDefault(s.Balances, queriedAccount, curr, func() *big.Int {
return new(big.Int).Set(s.DefaultBalance)
})
func (s *TestInitStore) GetBalances(ctx context.Context, q interpreter.BalanceQuery) (interpreter.Balances, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Seed default balances for scaling wildcard queries

For test-init on an asset-scaling script, the preload query asks for a wildcard asset like USD/*. Starting from an empty store, StaticStore.GetBalances returns no rows for that catch-all query, so this wrapper never creates a default balance and the interpreter later raises InvalidUnboundedAddressInScalingAddress instead of generating a specs file. The previous store funded the queried wildcard, so this regresses test-init for scaling sources without preexisting balances.

balances, err := s.StaticStore.GetBalances(ctx, q)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Materialize wildcard balance queries in test-init

For asset-scaling sources the interpreter queries a wildcard asset like USD/*; when the account has no stored rows, StaticStore.GetBalances returns an empty slice for that wildcard, so the loop below never seeds the account with DefaultBalance. In scripts using source = @src with scaling through @swap, MakeSpecsFile then reaches scaling with no cached balance entry and returns InvalidUnboundedAddressInScalingAddress instead of generating a spec.

if err != nil {
return nil, err
}

outputAccountBalance := utils.MapGetOrPutDefault(outputBalance, queriedAccount, func() interpreter.AccountBalance {
return interpreter.AccountBalance{}
})
type key struct{ account, asset, color string }

outputAccountBalance[curr] = new(big.Int).Set(amt)
}
// StaticStore.GetBalances materializes a zero-amount row for every queried
// (account, asset, color), so its output can't tell a known account from an
// unknown one. Track what we've actually funded ourselves instead.
stored := make(map[key]struct{}, len(s.StaticStore.Balances))
for _, b := range s.StaticStore.Balances {
stored[key{b.Account, b.Asset, b.Color}] = struct{}{}
}

return outputBalance, nil
}
for i := range balances {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Seed defaults for catch-all balance queries

For test-init on scripts using asset scaling, the batched query is a catch-all like USD/*; with an empty generated store, StaticStore.GetBalances returns no rows for that query, so this loop never adds the default balance. The interpreter then has no cached account entry and MakeSpecsFile fails with InvalidUnboundedAddressInScalingAddress instead of generating a spec.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, but out of scope for this pr. It's a pre-existing bug, about an experimental, undocumented feature, and our pr has higher priority

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Seed defaults for wildcard test-init queries

For test-init on scripts using asset scaling, the preload balance query is a catch-all such as EUR/*. If the generated store is empty, StaticStore.GetBalances returns an empty slice, so this loop never seeds DefaultBalance into StaticStore; numscript test-init then fails before generating specs for scaling scripts with no preexisting balances.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scaling is out of scope for the PR

b := &balances[i]
k := key{b.Account, b.Asset, b.Color}
if _, ok := stored[k]; ok {
continue
}

func (s TestInitStore) GetAccountsMetadata(c context.Context, q interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) {
outputMeta := interpreter.AccountsMetadata{}
for queriedAccount, queriedCurrencies := range q {
for _, curr := range queriedCurrencies {
outputAccountMeta := utils.MapGetOrPutDefault(outputMeta, queriedAccount, func() interpreter.AccountMetadata {
return interpreter.AccountMetadata{}
})
outputAccountMeta[curr] = ""
// Unknown (account, asset, color): fund it with the default balance, and
// remember it so later queries (and the generated specs file) see it.
amount := new(big.Int)
if s.DefaultBalance != nil {
amount.Set(s.DefaultBalance)
}
b.Amount = amount

s.StaticStore.Balances = append(s.StaticStore.Balances, interpreter.BalanceRow{
Account: b.Account,
Asset: b.Asset,
Color: b.Color,
Amount: new(big.Int).Set(amount),
})
stored[k] = struct{}{}
}

return outputMeta, nil
return balances, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func (s *TestInitStore) GetAccountsMetadata(c context.Context, q interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Preserve metadata stubs in test init

When numscript test init runs a script that calls meta(...), this delegated store now returns the empty StaticStore.Meta map instead of materializing the requested account/key pairs. That makes the interpreter raise MetadataNotFound, whereas the previous TestInitStore returned "" for each queried metadata key so spec generation could complete. This should keep the test-init-specific stub behavior or populate StaticStore.Meta for the requested query.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Preserve metadata stubbing in test-init

For scripts that call meta(), test init constructs this store with an empty metadata map; delegating to StaticStore returns that empty map and ignores the requested keys, so meta() raises MetadataNotFound. The previous TestInitStore synthesized empty strings for every requested metadata key, allowing specs to be generated for scripts with metadata reads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Preserve metadata stubs in test-init

When numscript test-init is run for scripts that call meta(...), this now delegates to StaticStore, which returns the empty metadata map instead of synthesizing the requested account/key pairs as TestInitStore did before. In that scenario (for example vars { string $s = meta(@a, "k") }) meta() raises MetadataNotFound, so specs can no longer be generated for metadata-driven scripts.

return s.StaticStore.GetAccountsMetadata(c, q)
}
4 changes: 1 addition & 3 deletions internal/cmd/test_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ func TestMakeSpecsFileRetryForMissingFunds(t *testing.T) {

require.Nil(t, err)
require.Equal(t, interpreter.Balances{
"alice": interpreter.AccountBalance{
"USD/2": big.NewInt(10000),
},
{Account: "alice", Asset: "USD/2", Amount: big.NewInt(10000)},
}, out.Balances)
}

Expand Down
3 changes: 3 additions & 0 deletions internal/interpreter/accounts_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"github.com/formancehq/numscript/internal/utils"
)

type AccountMetadata = map[string]string
type AccountsMetadata map[string]AccountMetadata

func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata {
return utils.MapGetOrPutDefault(m, account, func() AccountMetadata {
return AccountMetadata{}
Expand Down
8 changes: 5 additions & 3 deletions internal/interpreter/args_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ func NewArgsParser(args []Value) *argsParser {
}
}

func parseArg[T any](p *argsParser, r parser.Range, expect func(Value, parser.Range) (*T, InterpreterError)) *T {
func parseArg[T any](p *argsParser, r parser.Range, expect func(Value, parser.Range) (T, InterpreterError)) T {
index := p.parsedArgsCount
p.parsedArgsCount++

if p.err != nil || index >= len(p.args) {
return nil
var default_ T
return default_
}

arg := p.args[index]
parsed, err := expect(arg, r)
if err != nil {
p.err = err
return nil
var default_ T
return default_
}
return parsed
}
Expand Down
4 changes: 2 additions & 2 deletions internal/interpreter/args_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ func TestParseValid(t *testing.T) {
require.NotNil(t, a1, "a1 should not be nil")
require.NotNil(t, a2, "a2 should not be nil")

require.Equal(t, *a1, *big.NewInt(42))
require.Equal(t, *a2, "user:001")
require.Equal(t, a1, MonetaryInt(*big.NewInt(42)))
require.Equal(t, a2, AccountAddress("user:001"))
}

func TestParseBadType(t *testing.T) {
Expand Down
38 changes: 15 additions & 23 deletions internal/interpreter/asset_scaling.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import (
"fmt"
"math/big"
"slices"
"strconv"
"strings"

"github.com/formancehq/numscript/internal/utils"
)

func assetToScaledAsset(asset string) string {
parts := strings.Split(asset, "/")
func assetToScaledAsset(asset Asset) Asset {
strAsset := string(asset)
parts := strings.Split(strAsset, "/")
if len(parts) == 1 {
return asset + "/*"
return Asset(strAsset + "/*")
}
return parts[0] + "/*"
return Asset(parts[0] + "/*")
}

func buildScaledAsset(baseAsset string, scale int64) string {
Expand All @@ -25,25 +25,17 @@ func buildScaledAsset(baseAsset string, scale int64) string {
return fmt.Sprintf("%s/%d", baseAsset, scale)
}

func getAssetScale(asset string) (string, int64) {
parts := strings.Split(asset, "/")
if len(parts) == 2 {
scale, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil {
return parts[0], scale
}
// fallback if parsing fails
return parts[0], 0
}
return asset, 0
}

func getAssets(balance AccountBalance, baseAsset string) map[int64]*big.Int {
func getAssets(accountBalances []AccountBalance, baseAsset string) map[int64]*big.Int {
result := make(map[int64]*big.Int)
for asset, amount := range balance {
if strings.HasPrefix(asset, baseAsset) {
_, scale := getAssetScale(asset)
result[scale] = amount
for _, accBalance := range accountBalances {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Do not let scaling spend colored balances

When a script has cached a colored balance for the same account/base asset and then uses with scaling, this loop includes that colored row as a conversion candidate even though the scaling path later emits and subtracts colorless postings. For example, a cached RED USD/2 balance can be treated as available uncolored USD/2, allowing scaling to create postings the ledger cannot fund from uncolored balances.

if accBalance.Color != "" {
// scaling converts only uncolored balances, and emits uncolored
// postings, so a colored balance must not be treated as available
continue
}
accBalanceAsset, scale := Asset(accBalance.Asset).GetBaseAndScale()
if accBalanceAsset == baseAsset {
result[scale] = new(big.Int).Set(accBalance.Amount)
}
}
return result
Expand Down
16 changes: 16 additions & 0 deletions internal/interpreter/asset_scaling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ import (
"github.com/stretchr/testify/require"
)

func TestGetAssetsExcludesColoredBalances(t *testing.T) {
// Scaling converts only uncolored balances and emits uncolored postings, so a
// colored balance for the same base asset must not be offered as a candidate.
assets := getAssets([]AccountBalance{
{Asset: "USD", Color: "", Amount: big.NewInt(2)},
{Asset: "USD/4", Color: "RED", Amount: big.NewInt(999)}, // colored: excluded
{Asset: "USD/2", Color: "", Amount: big.NewInt(50)},
{Asset: "EUR", Color: "", Amount: big.NewInt(7)}, // different base: excluded
}, "USD")

require.Equal(t, map[int64]*big.Int{
0: big.NewInt(2),
2: big.NewInt(50),
}, assets)
}

func TestScalingAvoidSwappingAlreadyHaveAsset(t *testing.T) {
// Need [USD/2 200]
// Got: {USD/2 100, USD 2}
Expand Down
Loading