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
73 changes: 66 additions & 7 deletions cmd/ncly/script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,71 @@ package main

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"testing"
"time"

"github.com/pascalandy/nicely/sdk/extension"
"github.com/pascalandy/nicely/sdk/runner"
hello "github.com/pascalandy/nicely/testdata/extensions/hello"
"github.com/rogpeppe/go-internal/testscript"
)

func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){"ncly": testNcly, "closedpipe": closedPipe, "exitcode": exitCode})
testscript.Main(m, map[string]func(){
"ncly": testNcly,
"closedpipe": closedPipe,
"exitcode": exitCode,
"hello-external": func() { shortCleanup(); extension.Main(hello.Extension) },
})
}

// testNcly runs ncly with 4 seconds of cleanup instead of 15, so that a
// scenario reaches the SIGKILL of cleanup quickly. The drain limit keeps its
// 2 seconds, so the scenarios still tell the two clocks apart.
// fixtures holds the Go fixture extensions of testdata/extensions/, by
// domain. The setup of a fixture's scenarios installs it in the data folder.
var fixtures = map[string]extension.Extension{"hello": hello.Extension}

// testNcly runs ncly with a short cleanup.
func testNcly() {
runner.Cleanup = 4 * time.Second
shortCleanup()
main()
}

// shortCleanup gives cleanup 4 seconds instead of 15, so that a scenario
// reaches the SIGKILL of cleanup quickly. The drain limit keeps its 2
// seconds, so the scenarios still tell the two clocks apart.
func shortCleanup() { runner.Cleanup = 4 * time.Second }

// TestScripts runs core's scenarios, then those of each extension and of
// each fixture extension, which keep theirs in their own folder.
func TestScripts(t *testing.T) {
root := filepath.Join("..", "..")
t.Run("core", func(t *testing.T) { runScripts(t, filepath.Join(root, "testdata", "script"), setupHome) })
extensions, _ := filepath.Glob(filepath.Join(root, "extensions", "*", "testdata", "script"))
fixtureDirs, _ := filepath.Glob(filepath.Join(root, "testdata", "extensions", "*", "testdata", "script"))
for _, dir := range append(extensions, fixtureDirs...) {
domain := filepath.Base(filepath.Dir(filepath.Dir(dir)))
t.Run(domain, func(t *testing.T) {
runScripts(t, dir, func(env *testscript.Env) error {
if err := setupHome(env); err != nil {
return err
}
if e, isFixture := fixtures[domain]; isFixture {
return install(env, domain, e)
}
return nil
})
})
}
}

func runScripts(t *testing.T, dir string, setup func(*testscript.Env) error) {
testscript.Run(t, testscript.Params{
Dir: filepath.Join("..", "..", "testdata", "script"),
Dir: dir,
RequireExplicitExec: true,
RequireUniqueNames: true,
Setup: setupHome,
Setup: setup,
Condition: condition,
Cmds: map[string]func(*testscript.TestScript, bool, []string){
"exits": cmdExits,
Expand All @@ -43,6 +81,27 @@ func TestScripts(t *testing.T) {
})
}

// install puts a Go fixture in the data folder as an external extension:
// the script ncly-<domain> that runs it, with its manifest and catalogs
// beside it. The script runs the test binary under a name other than
// ncly-<domain>, so the fixture never runs as a bundled extension here.
func install(env *testscript.Env, domain string, e extension.Extension) error {
dir := filepath.Join(env.WorkDir, ".local", "share", "nicely", "extensions")
executable := filepath.Join(dir, "ncly-"+domain)
locales, err := fs.Sub(e.Locales, "locales")
if err == nil {
err = os.CopyFS(executable+".locales", locales)
}
if err == nil {
err = os.WriteFile(executable+".toml", e.Manifest, 0o644)
}
if err == nil {
err = os.WriteFile(executable, []byte("#!/bin/sh\nexec "+domain+"-external \"$@\"\n"), 0o755)
}
env.Setenv("DATA", dir)
return err
}

// setupHome points HOME and the XDG folders at $WORK, so ncly never touches
// the host's own config, data, state, or cache.
func setupHome(env *testscript.Env) error {
Expand Down
20 changes: 15 additions & 5 deletions cmd/ncly/scriptcmds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ func shellStatus(state *os.ProcessState) int {
}

var (
marked = regexp.MustCompile(`(?s)⟦.*?⟧`)
// marked matches an innermost marked text, since a translated text can
// hold another, such as a greeting inside a sentence.
marked = regexp.MustCompile(`⟦[^⟦⟧]*⟧`)
example = regexp.MustCompile(`(?m)^\s*\$ .*$`)
letter = regexp.MustCompile(`\p{L}`)
version = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)*$`)
Expand All @@ -251,8 +253,12 @@ func cmdPseudo(ts *testscript.TestScript, neg bool, args []string) {
if !marked.MatchString(text) {
ts.Fatalf("pseudo: no marked text on %s", args[0])
}
text = example.ReplaceAllString(text, "")
for marked.MatchString(text) {
text = marked.ReplaceAllString(text, " ")
}
var stray []string
for _, word := range strings.Fields(marked.ReplaceAllString(example.ReplaceAllString(text, ""), " ")) {
for _, word := range strings.Fields(text) {
word = strings.Trim(word, `.,:;!?()"'`)
switch {
case !letter.MatchString(word),
Expand All @@ -271,11 +277,15 @@ func cmdPseudo(ts *testscript.TestScript, neg bool, args []string) {
}
}

// isCode reports whether a word is a registered error or warning code, the
// only uppercase words that stay untranslated.
// codeForm is the form of an extension's code, such as HELLO_GRUMPY.
var codeForm = regexp.MustCompile(`^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$`)

// isCode reports whether a word is a code, the only uppercase words that
// stay untranslated: one of core's, or one of the form of an extension's,
// whose set is open.
func isCode(word string) bool {
_, isError := contract.Code(word).Exit()
return isError || contract.Code(word).IsWarning()
return isError || contract.Code(word).IsWarning() || codeForm.MatchString(word)
}

// cmdWaitfile waits until a file exists, such as the process ID that a
Expand Down
2 changes: 1 addition & 1 deletion docs/milestones/M02-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The SDK is the Go implementation of the contract between core and its extensions
|---|---|---|---|---|
| M02-T1 | Shared packages in sdk | agent | — | done |
| M02-T2 | Import boundary | agent | M02-T1 | done |
| M02-T3 | Extension entry point | agent | M02-T1 | todo |
| M02-T3 | Extension entry point | agent | M02-T1 | done |
| M02-T4 | Bundled transport | agent | M02-T3 | todo |

### M02-T1 Shared packages in sdk
Expand Down
2 changes: 1 addition & 1 deletion docs/north-star/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The other folders:
- `cmd/ncly/`: the entry point, which passes core the bundled extensions in an explicit list, and runs one of them when started under its name
- `internal/specdoc/`: reads the tables of the north-star docs for the tests that compare code and spec
- `testdata/script/`: core's testscript scenarios. An extension keeps its own in `extensions/<name>/testdata/script/`, and the test binary of `cmd/ncly` runs both
- `testdata/extensions/hello/`: the Go fixture extension that proves the SDK, laid out as a first-party extension. `./...` skips it, so `just lint` names it
- `testdata/extensions/hello/`: the Go fixture extension that proves the SDK, laid out as a first-party extension. `./...` skips it, so `just lint` and `just test` name it
- `tools/`: a separate module that pins the dev tools and holds the plan checker behind `just next` and `just test`

## Domains and commands
Expand Down
6 changes: 3 additions & 3 deletions docs/north-star/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,17 +381,17 @@ func Check(e Extension) error
`Main` parses the call with the manifest, prints the help of a group, and calls the handler of the path. It prints the result by its kind, from the command's `result`:

- `object` and `batch`: in human mode, the handler writes its text to `Stdout`. With `--json`, it writes nothing there, and the SDK prints the answer from the outcome
- `report`: the handler returns its findings in the outcome's data, and marks the outcome when a finding needs a human. The SDK answers on stdout with `ok` and exit 0, or `false` and exit 78, without `errors`, as [Output](#output) says. Core's doctor uses the same outcome from M05
- `report`: in human mode, the handler writes its report to `Stdout`. With `--json`, the SDK prints the findings from the outcome's data on stdout, in an answer without `errors`, as [Output](#output) says. In both modes, the handler returns its findings in that data, and sets the outcome's `HumanNeeded` when a finding needs a human, which makes the exit code 78 instead of 0, and `ok` false with `--json`. Core's doctor uses the same outcome from M05
- `text`: the handler writes its text to `Stdout`, whatever `--json` says

A failure of the command itself prints as an ordinary failure for every kind: the error block, or the answer on stderr with `--json`. On Ctrl-C or SIGTERM, the SDK ends `ctx`, stops the extension's programs as [Programs that ncly runs](#programs-that-ncly-runs) defines, and sets the outcome's signal, so a handler returns what it has. A handler that has not returned when the 15 seconds of cleanup end gets no more time: the SDK answers `INTERRUPTED` or `TERMINATED` alone and exits. Under `ncly`, core's own 15 seconds govern, and either path gives the same answer, because both carry the signal's code and no data key. Run directly, `ncly-<domain>` also parses the global flags and the ordinary syntax that core accepts, because canonical arguments are a subset of it. `Check` reports a manifest that breaks the rules of [Manifest](#manifest), a command that runs without a handler, a handler for no such command, and a catalog that breaks the catalog rules below. Every Go extension has a test that calls it. `Main` checks the manifest and the handlers when it starts and answers `RUNTIME` for those faults, while a missing translation falls back to `en` at run time, as [Text of an extension](#text-of-an-extension) says.
A failure of the command itself prints as an ordinary failure for every kind: the error block, or the answer on stderr with `--json`. On Ctrl-C or SIGTERM, the SDK ends `ctx`, stops the extension's programs as [Programs that ncly runs](#programs-that-ncly-runs) defines, and sets the outcome's signal, so a handler returns what it has. A handler that has not returned once the 15 seconds of cleanup and the 2 seconds of the drain limit have passed, the time that the SIGKILL of cleanup gives its programs to end, gets no more time: the SDK answers `INTERRUPTED` or `TERMINATED` alone and exits. Under `ncly`, core's own 15 seconds govern, and either path gives the same answer, because both carry the signal's code and no data key. Run directly, `ncly-<domain>` also parses the global flags and the ordinary syntax that core accepts, because canonical arguments are a subset of it, and `--version` prints the version of the module that built it. `Check` reports a manifest that breaks the rules of [Manifest](#manifest), a command that runs without a handler, a handler for no such command, and a catalog that breaks the catalog rules below. Every Go extension has a test that calls it. `Main` checks the manifest and the handlers when it starts and answers `RUNTIME` for those faults, while a missing translation falls back to `en` at run time, as [Text of an extension](#text-of-an-extension) says.

The same value serves both transports, so they cannot drift, as [D051](decisions/D051-bundled-child-process.md) decides:

- **External:** the extension's `main` calls `extension.Main`. Installing it writes the embedded manifest and catalogs beside the executable, as `ncly-<domain>.toml` and `ncly-<domain>.locales/`
- **Bundled:** `cmd/ncly` passes the value to core in an explicit list, never through an `init` side effect. Core reads its embedded manifest and catalogs, validates its call, then starts the `ncly` binary itself as the extension's program, with `ncly-<domain>` as its first argument, `argv[0]`, through the program runner. Started under the name of a bundled extension, `ncly` runs that extension's `Main` instead of core, as busybox picks its applet from its name. A bundled extension therefore gets the canonical arguments, the environment, the process group, the cancellation, and the answer check of an external one

The SDK accepts the error codes that the manifest declares, and every warning code that starts with the domain and an underscore, beside core's codes, so the verdict and the answer follow [Output](#output). The SDK's catalog holds the text that the SDK renders: help pages, global flags, usage errors, error labels, outcome summaries, and size units. An extension's printer takes an ID that starts with its domain and a dot from the extension's catalogs, and any other ID from the SDK's, so an extension never overrides the SDK's text. Core's printer takes an ID from core's catalog when that catalog holds it, and from the SDK's otherwise. The catalog rules are these: every entry has a description, every language holds exactly the IDs of `en`, an extension's IDs start with its domain and a dot, and core's IDs differ from the SDK's. `Check` applies them to an extension, and one parity test applies them to every catalog folder of the repository, core's, the SDK's, and each extension's.
The SDK accepts the error codes that the manifest declares, and every warning code that starts with the domain and an underscore, beside core's codes, so the verdict and the answer follow [Output](#output). The SDK's catalog holds the text that the SDK renders: help pages, global flags, usage errors, error labels, outcome summaries, size units, and the error of an extension that does not match its own manifest. An extension's printer takes an ID that starts with its domain and a dot from the extension's catalogs, and any other ID from the SDK's, so an extension never overrides the SDK's text. Core's printer takes an ID from core's catalog when that catalog holds it, and from the SDK's otherwise. The catalog rules are these: every entry has a description, every language holds exactly the IDs of `en`, an extension's IDs start with its domain and a dot, and core's IDs differ from the SDK's. `Check` applies them to an extension, and one parity test applies them to every catalog folder of the repository, core's, the SDK's, and each extension's.

## Operations

Expand Down
51 changes: 16 additions & 35 deletions internal/cli/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,17 @@ func (c *call) run(p *i18n.Printer, g command.Globals, stdout, stderr *os.File)
data := map[string]any{"Domain": c.extension.domain, "Exit": result.Exit}
switch {
case err != nil:
return command.Report(p, g, protocolFailure(p, "extension.start_failed", data), stdout, stderr)
return command.Report(p, g, contract.Core, protocolFailure(p, "extension.start_failed", data), stdout, stderr)
case result.Signal != nil:
// The extension's own text already told the human, unless its exit
// code says otherwise.
stopped := contract.Outcome{Signal: stopSignal(result.Signal)}
if verdict := contract.Decide(stopped); result.Exit == int(verdict.Exit) {
stopped := contract.Outcome{Signal: contract.StopSignal(result.Signal)}
if verdict := contract.Core.Decide(stopped); result.Exit == int(verdict.Exit) {
return int(verdict.Exit)
}
return command.Report(p, g, stopped, stdout, stderr)
return command.Report(p, g, contract.Core, stopped, stdout, stderr)
case !contract.ExitCode(result.Exit).Known():
return command.Report(p, g, protocolFailure(p, "extension.answer_exit", data), stdout, stderr)
return command.Report(p, g, contract.Core, protocolFailure(p, "extension.answer_exit", data), stdout, stderr)
}
return result.Exit
}
Expand All @@ -90,7 +90,7 @@ func (c *call) runChecked(ctx context.Context, p *i18n.Printer, g command.Global
result, err := runner.Run(ctx, program)
if err != nil {
data := map[string]any{"Domain": c.extension.domain}
return command.Report(p, g, protocolFailure(p, "extension.start_failed", data), stdout, stderr)
return command.Report(p, g, contract.Core, protocolFailure(p, "extension.start_failed", data), stdout, stderr)
}
reply := contract.Reply{Exit: result.Exit, Stdout: held.Bytes(), Last: lines.finish()}
answer, err := contract.CheckAnswer(c.extension.manifest, c.kind, reply)
Expand All @@ -111,11 +111,11 @@ func (c *call) runChecked(ctx context.Context, p *i18n.Printer, g command.Global
case result.Signal != nil && err == nil:
// The first signal decides the verdict, and a valid answer keeps its
// keys, as Answer check in contract.md asks.
return reportKept(p, g, contract.Outcome{Signal: stopSignal(result.Signal)}, answer, stdout, stderr)
return reportKept(p, g, contract.Outcome{Signal: contract.StopSignal(result.Signal)}, answer, stdout, stderr)
case result.Signal != nil:
return command.Report(p, g, contract.Outcome{Signal: stopSignal(result.Signal)}, stdout, stderr)
return command.Report(p, g, contract.Core, contract.Outcome{Signal: contract.StopSignal(result.Signal)}, stdout, stderr)
case errors.As(err, &rule):
return command.Report(p, g, protocolFailure(p, rule.Rule, rule.Data), stdout, stderr)
return command.Report(p, g, contract.Core, protocolFailure(p, rule.Rule, rule.Data), stdout, stderr)
}
if _, err := answerOut.Write(answer.Line); err != nil {
if answerOut == stdout {
Expand All @@ -126,23 +126,15 @@ func (c *call) runChecked(ctx context.Context, p *i18n.Printer, g command.Global
return result.Exit
}

// stopSignal names the signal that stopped an extension in the contract.
func stopSignal(sig os.Signal) contract.Signal {
if sig == os.Interrupt {
return contract.Interrupt
}
return contract.Terminate
}

// reportKept answers the failure that o decides with the other keys of an
// accepted answer, because the work that the answer describes happened, as
// Output in contract.md asks. It returns the exit code of o, or 1 when
// stderr refuses the answer too.
func reportKept(p *i18n.Printer, g command.Globals, o contract.Outcome, answer contract.ExtensionAnswer, stdout, stderr io.Writer) int {
a := contract.Finish(o, command.Summarizer(p))
a := contract.Core.Finish(o, command.Summarizer(p))
line, err := answer.Failed(a.Errors()[0])
if err != nil {
return command.Report(p, g, o, stdout, stderr)
return command.Report(p, g, contract.Core, o, stdout, stderr)
}
if _, err := stderr.Write(append(line, '\n')); err != nil {
return int(contract.ExitRuntime)
Expand Down Expand Up @@ -196,21 +188,10 @@ func (h *heldLines) finish() []byte {
// extensionVars carries the resolved global flags and the identity of ncly
// to an extension. An empty value removes the variable.
func extensionVars(g command.Globals) map[string]string {
on := func(set bool) string {
if set {
return "1"
}
return ""
}
self, _ := os.Executable()
return map[string]string{
"NCLY_JSON": on(g.JSON),
"NCLY_NO_INPUT": on(g.NoInput),
"NCLY_VERBOSE": on(g.Verbose),
"NO_COLOR": on(g.NoColor),
"NCLY_LANG": i18n.Requested(g.Lang),
"NCLY_VERSION": releaseVersion(),
"NCLY_CONTRACT_VERSION": strconv.Itoa(contract.Version),
"NCLY_BIN": self,
}
vars := g.Vars()
vars["NCLY_VERSION"] = releaseVersion()
vars["NCLY_CONTRACT_VERSION"] = strconv.Itoa(contract.Version)
vars["NCLY_BIN"] = self
return vars
}
Loading