Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkgs/mcpx/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ skipped launcher phases).
| --- | --- | --- | --- | --- | --- | --- | --- |
| `script.before` | source | *(empty)* | client | no | `--script-before` | `MCPX_SCRIPT_BEFORE` | runs first, ahead of the globals being installed |
| `script.captureConsole` | bool | `true` | client | no | `--script-capture-console` | `MCPX_SCRIPT_CAPTURE_CONSOLE` | route console calls into the log |
| `script.check` | enum | `off` | client | no | `--script-check`, `--script-typecheck`, `--typecheck` | `MCPX_SCRIPT_CHECK`, `MCPX_SCRIPT_TYPECHECK` | check the generated program before running it; path alias: script.typecheck; one of off, types (also on), strict |
| `script.cwd` | enum | `caller` | client | no | `--script-cwd` | `MCPX_SCRIPT_CWD` | where a script runs: caller, script, config or workdir |
| `script.env` | list | *(empty)* | client | no | `--script-env`, `--env` | `MCPX_SCRIPT_ENV` | KEY=VALUE pairs added to the script's environment |
| `script.imports` | enum | `report` | client | no | `--script-imports` | `MCPX_SCRIPT_IMPORTS` | resolve the script's import graph before running it |
Expand All @@ -1027,7 +1028,6 @@ skipped launcher phases).
| `script.runtimeOrder` | list | `deno,bun,node` | client | no | `--script-runtime-order` | `MCPX_SCRIPT_RUNTIME_ORDER` | the runtimes auto tries, in order |
| `script.runtimes` | string | *(empty)* | client | no | `--script-runtimes` | `MCPX_SCRIPT_RUNTIMES` | named runtime binaries: {name: {kind, bin, args}} |
| `script.suffix` | source | *(empty)* | client | no | `--script-suffix` | `MCPX_SCRIPT_SUFFIX` | runs last on both paths, like a finally |
| `script.typecheck` | enum | `off` | client | no | `--script-typecheck` | `MCPX_SCRIPT_TYPECHECK` | check the generated program before running it |

### search

Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/docs/exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ type ExecOptions = {
ns?: string[];
args?: string[];
export?: string;
typecheck?: "off" | "on" | "strict";
typecheck?: "off" | "types" | "strict" | "on"; // on is a legacy alias for types
launcher?: string;
launcherName?: string; // template text, or "none"
allowRepeat?: string[];
Expand Down
8 changes: 5 additions & 3 deletions pkgs/mcpx/docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ writer hits first:
properties — that Node itself runs fine once types are
transformed. An `enum` in a script is guarded by a test
(`TestNodeRunsNonErasableTypeScript`), not a limitation.
- **Typechecking is Deno-only.** `--typecheck` /
`script.typecheck` runs the check (via `deno check`) before
the run and only applies when Deno is the runtime.
- **Typechecking is Deno-only.** `--typecheck` / `script.typecheck`
remain accepted as legacy spellings for `--script-check` /
`script.check`. `types` resolves imports and checks types with `deno check`,
and `strict` also checks declaration files. The old value `on` means `types`.
Non-Deno runtimes warn and continue because they strip types rather than checking them.

### Authority

Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/api/ops_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const execOptionsSchema = `{"type":"object","properties":{
"ns":{"type":"array","items":{"type":"string"},"description":"restrict the generated client to these namespaces"},
"args":{"type":"array","items":{"type":"string"},"description":"arguments handed to the script"},
"export":{"type":"string","description":"call this exported function instead of the default one"},
"typecheck":{"type":"string","enum":["off","on","strict"],"description":"check the program before running it; default from settings"},
"typecheck":{"type":"string","enum":["off","types","strict","on"],"description":"check the program before running it; on is a legacy alias for types; default from settings"},
"launcher":{"type":"string","description":"launcher template text replacing the generated one, or none for no launcher"},
"launcherName":{"type":"string","description":"what to call the launcher in messages"},
"allowRepeat":{"type":"array","items":{"type":"string"},"description":"launcher placeholders permitted to resolve more than once"},
Expand Down
9 changes: 4 additions & 5 deletions pkgs/mcpx/internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,7 @@ func (a *App) runScript(ctx context.Context, args []string, inline bool) error {
noLauncher := fs.Bool("no-launcher", false,
"run the script with no launcher: no globals, no capture, no wrapper")
typecheck := fs.String("typecheck", "",
"check the program before running it: off, on, strict")
"check the program before running it: off, types, strict (on aliases types)")
promptFlag := fs.String("prompt", "",
"synthesize and execute a script from a natural language prompt")
validateFlag := fs.Bool("validate", false,
Expand Down Expand Up @@ -2416,10 +2416,9 @@ func (a *App) resolveScriptShape(cfg *config.Config, f scriptFlags) (scriptShape
sh.onSuccess = cfg.ScriptPhase("onSuccess", sh.hooks.phaseValues("script.onSuccess", f.onSuccess))
sh.onError = cfg.ScriptPhase("onError", sh.hooks.phaseValues("script.onError", f.onError))

// Read from the resolved set, so the value is whichever layer won: the
// short --typecheck spelling, the generated --script-typecheck, a
// variable, or the config file.
sh.typecheck = firstNonEmpty(f.typecheck, a.Settings().String("script.typecheck"))
// Read the canonical setting after resolution so legacy path, variable,
// and flag spellings all pass through the same value normalization.
sh.typecheck = a.Settings().String("script.check")

// No short flag spelling: the registry value is the whole story, from
// the generated --script-imports flag, a variable, or the config file.
Expand Down
4 changes: 2 additions & 2 deletions pkgs/mcpx/internal/cli/flagindex_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/daemon/routes_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func (s *Server) ExecService() *execsvc.Service {
lim.Setup, lim.SetupErr = runner.ParseSetup(set.String("script.runtimes"),
set.List("script.runtimeOrder"), set.String("script.profiles"))
lim.CaptureConsole = set.Bool("script.captureConsole")
lim.Typecheck = set.String("script.typecheck")
lim.Typecheck = set.String("script.check")
lim.Imports = set.String("script.imports")
lim.ImportsOnDynamic = set.String("script.importsOnDynamic")
lim.Delivery = set.String("artifacts.delivery")
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/e2e/effect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ var implementedEffectProbes = map[string]string{
"script.interpreter": "TestScriptInterpreterIsReachableFromTheEnvironment",
"script.env": "TestScriptEnvIsReachableFromTheEnvironment",
"script.captureConsole": "TestScriptCaptureConsoleOffDropsConsoleOutput",
"script.typecheck": "TestScriptTypecheckOnRefusesATypeError",
"script.check": "TestScriptTypecheckOnRefusesATypeError",
"logging.file": "TestLoggingFileOffWritesNoDurableLog",
"plumbing.strictUnknownKeys": "TestStrictUnknownKeysRefusesATypo",
"paths.state": "TestStateDirectoryIsReachableAsASetting",
Expand Down
3 changes: 2 additions & 1 deletion pkgs/mcpx/internal/execsvc/execsvc.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ type Options struct {
// Phase lines and the launcher arrive as source: a path would name the
// daemon's filesystem, and the caller meant theirs.

// TypeCheck is off, on or strict; empty takes the daemon's default.
// TypeCheck is off, syntax, types or strict; on remains an alias for types.
// Empty takes the daemon's default.
TypeCheck string `json:"typecheck,omitempty"`
// Imports is off or report; empty takes the daemon's default.
Imports string `json:"imports,omitempty"`
Expand Down
13 changes: 7 additions & 6 deletions pkgs/mcpx/internal/preflight/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ type TypeCheckMode string

const (
TypeCheckOff TypeCheckMode = "off"
TypeCheckOn TypeCheckMode = "on"
TypeCheckTypes TypeCheckMode = "types"
TypeCheckOn TypeCheckMode = TypeCheckTypes // legacy name for callers
TypeCheckStrict TypeCheckMode = "strict"
)

Expand Down Expand Up @@ -226,8 +227,8 @@ func TypeCheckResolved(ctx context.Context, runtimeBin, entry string, mode TypeC
// equivalent single command. Saying so is better than pretending the
// check ran.
r.Add(Problem{
What: "type checking is only available under Deno; " + base + " strips types rather than checking them",
Where: "script.typecheck",
What: "script checking is only available under Deno; " + base + " strips types rather than checking them",
Where: "script.check",
})
return r
}
Expand Down Expand Up @@ -256,14 +257,14 @@ func TypeCheckResolved(ctx context.Context, runtimeBin, entry string, mode TypeC
if cctx.Err() != nil {
r.Add(Problem{
What: "type checking did not finish within " + timeout.String(),
Where: "script.typecheck",
Fix: "raise the timeout, or set script.typecheck off",
Where: "script.check",
Fix: "raise the timeout, or set script.check off",
})
return r
}
r.Add(Problem{
What: "the program does not type check:\n" + indent(strings.TrimSpace(string(out))),
Where: "script.typecheck",
Where: "script.check",
Fatal: true,
})
return r
Expand Down
36 changes: 36 additions & 0 deletions pkgs/mcpx/internal/preflight/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,42 @@ func TestTypeCheckIsSkippedWhenOff(t *testing.T) {
}
}

func TestScriptCheckModesSelectTypesAndStrictCommands(t *testing.T) {
dir := t.TempDir()
entry := filepath.Join(dir, "main.ts")
if err := os.WriteFile(entry, []byte("export {};\n"), 0o644); err != nil {
t.Fatal(err)
}
marker := filepath.Join(dir, "args")
stub := filepath.Join(dir, "deno-stub")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > '" + marker + "'\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}

for _, tc := range []struct {
mode preflight.TypeCheckMode
want []string
}{
{preflight.TypeCheckTypes, []string{"check", entry}},
{preflight.TypeCheckStrict, []string{"check", "--all", entry}},
} {
t.Run(string(tc.mode), func(t *testing.T) {
rep := preflight.TypeCheckResolved(context.Background(), stub, entry, tc.mode, 0, nil, dir)
if err := rep.Err(); err != nil {
t.Fatalf("check failed: %v", err)
}
got, err := os.ReadFile(marker)
if err != nil {
t.Fatal(err)
}
if fields := strings.Fields(string(got)); strings.Join(fields, "\x00") != strings.Join(tc.want, "\x00") {
t.Errorf("arguments = %q, want %q", fields, tc.want)
}
})
}
}

func TestTypeCheckSaysSoWhenTheRuntimeCannotDoIt(t *testing.T) {
// Node and Bun strip types rather than checking them. Saying so is
// better than pretending the check ran.
Expand Down
3 changes: 3 additions & 0 deletions pkgs/mcpx/internal/settings/configuration_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func ConfigurationTable(all []Setting) string {
envs = append(envs, "`"+a+"`")
}
governs := set.Short
if len(set.PathAliases) > 0 {
governs += "; path alias: " + strings.Join(set.PathAliases, ", ")
}
if len(set.EnumAliases) > 0 {
// Only where aliases exist: the canonical names are what a
// reader should write, and the aliases are what they may
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/settings/effect.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ var EffectProbes = map[string]string{
"logging.file": "TestLoggingFileOffWritesNoDurableLog",
"script.captureConsole": "TestScriptCaptureConsoleOffDropsConsoleOutput",
"script.prefix": "TestScriptPrefixIsReachableFromTheEnvironment",
"script.typecheck": "TestScriptTypecheckOnRefusesATypeError",
"script.check": "TestScriptTypecheckOnRefusesATypeError",
"script.env": "TestScriptEnvIsReachableFromTheEnvironment",
"paths.state": "TestStateDirectoryIsReachableAsASetting",
"registry.url": "TestRegistryPageSizeChangesWhatTheCLIAsksFor",
Expand Down
90 changes: 90 additions & 0 deletions pkgs/mcpx/internal/settings/pathalias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package settings_test

import (
"flag"
"strings"
"testing"

"github.com/developing-today/code/pkgs/mcpx/internal/settings"
)

func pathAliasSchema(t *testing.T) *settings.Schema {
t.Helper()
s, err := settings.New([]settings.Setting{{
Path: "new.name", PathAliases: []string{"old.name"},
Kind: settings.KindEnum, Enum: []string{"off", "types"},
EnumAliases: map[string][]string{"types": {"on"}}, Default: "off",
FlagAliases: []string{"old-name"}, EnvAliases: []string{"MCPX_OLD_NAME"},
Name: "Name", Short: "name",
}})
if err != nil {
t.Fatalf("build alias schema: %v", err)
}
return s
}

func TestPathAliasNormalizesAndStoresCanonicalPathFromEverySource(t *testing.T) {
s := pathAliasSchema(t)
sources := map[string]func(*settings.Set) error{
"file": func(set *settings.Set) error {
return s.ApplyFile(set, map[string]any{"old": map[string]any{"name": "on"}}, "/x/.mcpx.json", 0)
},
"env": func(set *settings.Set) error {
return s.ApplyEnv(set, []string{"MCPX_OLD_NAME=on"})
},
"flag": func(set *settings.Set) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(nopWriter{})
b := s.Bind(fs, "run")
if err := fs.Parse([]string{"--old-name", "on"}); err != nil {
return err
}
return b.ApplyTo(set)
},
"runtime": func(set *settings.Set) error { return set.SetRuntime("old.name", "on") },
}
for name, apply := range sources {
t.Run(name, func(t *testing.T) {
set := settings.NewSet(s)
if err := apply(set); err != nil {
t.Fatalf("apply alias: %v", err)
}
if got := set.String("new.name"); got != "types" {
t.Errorf("canonical path = %q, want types", got)
}
if got := set.String("old.name"); got != "types" {
t.Errorf("alias lookup = %q, want types", got)
}
if value, ok := set.Value("old.name"); !ok || value.Path != "new.name" {
t.Errorf("value = %#v, %v; want canonical path", value, ok)
}
})
}
}

func TestPathAliasCanonicalizesCallOverrides(t *testing.T) {
s := pathAliasSchema(t)
base := settings.NewSet(s)
view := base.WithOverrides(map[string]string{"old.name": "types"}, "request")
if got := view.String("new.name"); got != "types" {
t.Errorf("canonical path = %q, want types", got)
}
if got := view.String("old.name"); got != "types" {
t.Errorf("alias lookup = %q, want types", got)
}
}

func TestPathAliasesCannotCollideWithCanonicalOrOtherAliasPaths(t *testing.T) {
base := settings.Setting{Path: "new.name", PathAliases: []string{"old.name"}, Kind: settings.KindString, Name: "Name", Short: "name"}
for name, list := range map[string][]settings.Setting{
"canonical path": {base, {Path: "old.name", Kind: settings.KindString, Name: "Old", Short: "old"}},
"another alias": {base, {Path: "other.name", PathAliases: []string{"old.name"}, Kind: settings.KindString, Name: "Other", Short: "other"}},
"empty alias": {{Path: "new.name", PathAliases: []string{""}, Kind: settings.KindString, Name: "Name", Short: "name"}},
} {
t.Run(name, func(t *testing.T) {
if _, err := settings.New(list); err == nil || !strings.Contains(err.Error(), "path") {
t.Errorf("invalid alias declaration accepted: %v", err)
}
})
}
}
22 changes: 14 additions & 8 deletions pkgs/mcpx/internal/settings/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,14 +349,20 @@ func scriptSettings() []Setting {
Long: srcLong,
},
{
Path: "script.typecheck", Commands: runCommands, Kind: KindEnum, Default: "off",
Scope: ScopeClient,
Enum: []string{"off", "on", "strict"},
Name: "Type check",
Short: "check the generated program before running it",
Long: "Resolves and checks every import without executing anything. Costs " +
"a second or two on a cold module cache, which is why it is off by " +
"default rather than on.",
Path: "script.check", PathAliases: []string{"script.typecheck"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Replace legacy config entries when persisting the canonical path

When an existing project config contains {"script":{"typecheck":"on"}}, running mcpx settings set script.typecheck off --persist project against a running daemon now leaves the configuration with conflicting values. handleSettingsSet in internal/daemon/routes_settings.go writes decl.Path, now script.check, while config.SetKey only updates that leaf without removing script.typecheck. Subsequent invocations fail settings resolution because ApplyFile reads both spellings and Set.Apply rejects their differing values at the same layer. Persistent writes need to replace or remove existing alias entries when writing the canonical setting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve legacy paths before matching remote settings rows

With a daemon running, mcpx settings get script.typecheck --daemon now reports that the daemon has no such setting, even with a fresh configuration. The remote branch in internal/cli/settings_cmd.go:110–126 fetches the settings list and compares each returned r.Path against the literal requested path, but handleSettingsList returns only the canonical script.check spelling. Unlike the local branch, this lookup never resolves the new alias, breaking existing callers of the legacy path. Canonicalize the requested path before comparison or use the daemon’s individual-setting endpoint, which already supports aliases.

Useful? React with 👍 / 👎.

Commands: runCommands, Kind: KindEnum, Default: "off",
Scope: ScopeClient,
Enum: []string{"off", "types", "strict"},
EnumAliases: map[string][]string{"types": {"on"}},
FlagAliases: []string{"script-typecheck", "typecheck"},
EnvAliases: []string{"MCPX_SCRIPT_TYPECHECK"},
Name: "Script check",
Short: "check the generated program before running it",
Long: "types resolves imports and checks types; strict also checks " +
"declaration files. Checking is off by default. Type checking is " +
"currently supported by Deno. The old " +
"script.typecheck path, MCPX_SCRIPT_TYPECHECK variable, --script-typecheck " +
"flag and --typecheck flag remain accepted; the old value on means types.",
},
{
Path: "script.imports", Commands: runCommands, Kind: KindEnum, Default: "report",
Expand Down
Loading