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
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## [Unreleased]

Breaking. Placeholder syntax inside script bodies changed; catalogs need editing.
Full rules in [docs/contract.md](./docs/contract.md).

### Changed
- **Bodies are shell space; godo claims only `${godo:…}` there.** A matcher
capture is still *declared* `${NAME}` in the key and written `${NAME}` in
`@deps`, but consumed `${godo:argv[NAME]}` in the body. Every other `${…}` in
a body is passed to the host shell untouched.
*Migration:* `${NAME}` in a body must become `${godo:argv[NAME]}`. A body left
unedited does not error — the shell receives the braces and expands them to
nothing. There is no automated check for this yet.
- **Substituted values are shell-quoted.** One argument in is one argument out,
whatever it contains; shell metacharacters in a value are data, not syntax.
Previously values were interpolated verbatim into the `sh -c` / `cmd /C` line.
Ordinary tokens (flags, paths) stay unquoted so previews remain readable.
*Migration:* a script relying on an argument carrying a glob or a shell
operator needs `:raw`.
- **`@deps` forms a DAG.** A shared dependency runs once, at its first
(deepest-first) position, instead of once per path. Keyed on the expanded
invocation, so the same script reached with different captures still runs once
per capture set.
- **`@deps` entries resolve to tokens** instead of to a line that is then
re-split, so a capture holding a space no longer fragments the invocation.
- `Dialect.Match` takes a single `Script` instead of a slice.

### Added
- `${godo:argv[NAME]}` — consume a matcher capture in a body.
- `${godo:…:raw}` — opt one placeholder out of quoting.

### Removed
- The `$${…}` escape and the "unknown capture" failure for bare `${…}` in a
body. Both existed only to rescue host environment variables from being read
as captures.

### Notes
- The Windows quoting path is **untested**: CI runs Linux only, and the runner
tests skip on Windows. `cmd.exe` also expands `%VAR%` before a command sees
its arguments, which no command-line quoting fully suppresses — a value
containing `%` is not safe on Windows.

## [0.1.0] — 2026-09-13

### Added
Expand Down
46 changes: 35 additions & 11 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,43 @@ File default. Scripts may override with `# @dialect`.

## Bind

Godo expands placeholders **in-process** before `exec` / preview. No host-shell bind (`$VAR` / `%VAR%`).
Godo expands placeholders **in-process** before `exec` / preview, in two spaces:

| Placeholder | |
|-------------|--|
| `${name}` | Capture; `name` = `[A-Za-z_][A-Za-z0-9_]*` (fail-closed if invalid) |
| Space | Where | Syntax | Meets a shell |
|-------|-------|--------|---------------|
| godo | matcher keys, `@deps` | `${NAME}` | never |
| shell | script bodies | `${godo:…}` | always |

In a body godo claims **only** `${godo:…}`. Every other `${…}` is shell text and
is passed through untouched — `${HOME}` and `$$` are the host
shell's, never a catalog bind. Collision is structurally impossible, so there is
no escape syntax.

| Body placeholder | |
|------------------|--|
| `${godo:argv[NAME]}` | Capture; `NAME` = `[A-Za-z_][A-Za-z0-9_]*` (fail-closed if unbound or invalid) |
| `${godo:args}` | Remaining tokens (space-joined) |
| `${godo:args[i]}` | One token; **error** if out of range |
| `${godo:args[i..j]}` | Half-open slice `[i,j)` (Go style) |
| `${godo:…:raw}` | Any of the above, interpolated verbatim (no quoting) |

`${godo:argv[i]}` with a number is an error pointing at `${godo:args[i]}`.
`${godo:…}` inside a matcher key or a `@deps` entry is rejected.

Expanded values are **shell-quoted** for the host shell (`sh` / `cmd`): one
argument in is one argument out. `:raw` opts a single placeholder out.

Windows caveat: `cmd.exe` expands `%VAR%` and `!VAR!` before a command sees its
arguments, and no quoting on the command line fully suppresses that.

## Exec cwd

Commands run with working directory = **directory of the resolved `godo.yaml`** (not necessarily the caller’s cwd). Relative paths in the catalog stay stable from subdirs.

Trust: expanded lines pass through the host shell (`sh -c` / `cmd /C`).
Trust: expanded lines pass through the host shell (`sh -c` / `cmd /C`). Catalog
text is trusted — it is repo code. Values substituted into it are quoted, so
arguments and captures are data, not shell syntax; `${godo:…:raw}` waives that for one
placeholder and puts the trust decision back on the catalog author.

## Script decorators (JSDoc style)

Expand All @@ -103,17 +126,18 @@ ci: go build ./...
```yaml
# @deps lint ${MODULE}
# @dialect matcher
test ${MODULE}: go test ./${MODULE}/...
test ${MODULE}: go test ./${godo:argv[MODULE]}/...
```

- `@dialect` — per-script override; without it, uses `{file}.dialect`
- `@deps` / `@dependencies` — invocation like `godo …` (space-separated tokens; entries separated by `,`)
- literals and `${name}` from captures **already bound** by the match
- no `${godo:args}` / slices in `@deps`
- literals and bare `${NAME}` from captures **already bound** by the match (godo space)
- no `${godo:…}` in `@deps`; entries resolve to tokens, so a capture holding a space stays one token
- order = list order; stop on first failure
- cycle → error (on the **expanded** invocation)
- caller args are **not** forwarded to deps
- diamond (A→B,C and B→C): C may run more than once (**no** DAG dedup)
- diamond (A→B,C and B→C): C runs **once**, at its first (deepest-first) position
- dedup keys on the **expanded** invocation, so `lint pay` and `lint auth` are distinct nodes
- `--preview`: deps + body, in order (deps already expanded)
- no `@` → no deps (manual composition via `godo …` in the value remains valid)

Expand Down Expand Up @@ -156,7 +180,7 @@ scripts:
## Dialect `matcher`

Keys = routes over tokens (Express-style). First match in (definition order).
`${name}` = capture; the user chooses the name.
`${NAME}` = capture; the user chooses the name. Consume it in the body as `${godo:argv[NAME]}`.

```
scripts.<pattern>: string | string[]
Expand All @@ -169,7 +193,7 @@ dialect: matcher
scripts:
"${GRP} ${SCR}": go run -C scripts/${GRP}/${SCR} . ${godo:args}
# @deps lint ${MODULE}
test ${MODULE}: go test ./${MODULE}/...
test ${MODULE}: go test ./${godo:argv[MODULE]}/...
test: go test ./...
seed: go run -C scripts/service/seed . ${godo:args}
```
Expand Down
7 changes: 5 additions & 2 deletions docs/guide/deps.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ echo ship --prod

**No `${godo:args}` inside `@deps`.** Use captures already bound by a matcher key instead (`lint ${MODULE}`).

**Cycles error.** Diamond graphs may run a shared node more than once (no DAG dedup):
**Cycles error.** Deps form a DAG: a node several scripts depend on runs **once**, at its first (deepest-first) position.

```yaml
scripts:
Expand All @@ -91,10 +91,13 @@ godo --preview root
```text
echo leaf
echo mid
echo leaf
echo root
```

Dedup keys on the **expanded** invocation, not the script, so a matcher dep
reached with different captures stays distinct — `@deps lint pay, lint auth`
runs both.

## Next

- [Preview and ls](./preview-and-ls.md)
Expand Down
10 changes: 5 additions & 5 deletions docs/guide/matcher-dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ version: "0.1"
dialect: matcher

scripts:
test ${MODULE}: go test ./${MODULE}/...
test ${MODULE}: go test ./${godo:argv[MODULE]}/...
test: go test ./...
```

Expand Down Expand Up @@ -37,9 +37,9 @@ go test ./...

```yaml
scripts:
lint ${MODULE}: go run ./lint ${MODULE}
lint ${MODULE}: go run ./lint ${godo:argv[MODULE]}
# @deps lint ${MODULE}
test ${MODULE}: go test ./${MODULE}/...
test ${MODULE}: go test ./${godo:argv[MODULE]}/...
```

```bash
Expand All @@ -60,7 +60,7 @@ version: "0.1"

scripts:
# @dialect matcher
"run ${GRP} ${SCR}": go run ./scripts/${GRP}/${SCR} ${godo:args}
"run ${GRP} ${SCR}": go run ./scripts/${godo:argv[GRP]}/${godo:argv[SCR]} ${godo:args}
```

```bash
Expand All @@ -72,7 +72,7 @@ godo --ls run _ _
```text
@dialect matcher
run ${GRP} ${SCR}:
go run ./scripts/${GRP}/${SCR} ${godo:args}
go run ./scripts/${godo:argv[GRP]}/${godo:argv[SCR]} ${godo:args}
```

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/package-dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Package keys cannot contain `${capture}`. For patterns, use matcher (file-level
scripts:
test: go test ./...
# @dialect matcher
"run ${GRP} ${SCR}": go run ./scripts/${GRP}/${SCR} ${godo:args}
"run ${GRP} ${SCR}": go run ./scripts/${godo:argv[GRP]}/${godo:argv[SCR]} ${godo:args}
```

```bash
Expand Down
115 changes: 99 additions & 16 deletions docs/guide/placeholders.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,56 @@
# Placeholders

godo expands `${…}` **in process** before the shell runs the line. Host `$VAR` / `%VAR%` are not catalog binds.
godo works in **two spaces**, and each has its own syntax. The rule is one line:

## `${godo:args}`
> Bare braces where the text belongs to godo. `${godo:…}` where it belongs to the shell.

| Space | Where | Syntax | Meets a shell? |
|-------|-------|--------|----------------|
| godo | matcher keys, `@deps` entries | `${NAME}` | never |
| shell | script bodies | `${godo:…}` | always |

Because a body is shell text, godo claims **only** the `${godo:…}` namespace there
and leaves every other `${…}` alone. A collision is not resolved — it cannot occur.

```yaml
version: "0.1"
dialect: matcher

scripts:
# @deps lint ${MODULE} # godo space
build ${MODULE}: # godo space
- go build ./${godo:argv[MODULE]}/... # shell space
- echo $HOME and ${HOME} # the shell's, untouched
```

## `${godo:argv[NAME]}`

Consumes a capture bound by the matcher key.

```yaml
dialect: matcher

scripts:
build ${name}: echo building ${godo:argv[name]} -- ${godo:args}
```

```bash
godo --preview build api --release
```

**Prints:**

```text
echo building api -- --release
```

`${godo:argv[0]}` is an error on purpose: `argv` indexes captures by name. For a
positional argument use `${godo:args[0]}`.

## `${godo:args}`

Everything left over after the match.

```yaml
scripts:
seed: go run ./scripts/seed ${godo:args}
```
Expand All @@ -21,43 +65,80 @@ godo --preview seed --env=dev
go run ./scripts/seed --env=dev
```

## Matcher capture + leftovers
## Host environment variables

```yaml
dialect: matcher
Nothing to escape — bodies are shell text, so write shell:

```yaml
scripts:
build ${name}: echo building ${name} -- ${godo:args}
a: echo $HOME # shell expands
b: echo ${HOME} # shell expands
c: echo $$ # the shell's PID
```

godo does not read any of these, does not validate them, and does not fail on
them. They reach the shell byte for byte.

## Quoting

Values godo substitutes are **shell-quoted**: one argument in is one argument
out, whatever it contains.

```bash
godo --preview build api --release
godo --preview greet 'a; touch PWNED'
```

**Prints:**

```text
echo building api -- --release
echo hello 'a; touch PWNED'
```

Ordinary tokens are left bare so previews stay readable — `godo test -v ./...`
previews as `go test -v ./...`, not `go test '-v' './...'`.

### `:raw`

`:raw` turns quoting off for one placeholder, when the shell is meant to
interpret the value:

```yaml
scripts:
find: ls ${godo:args:raw}
```

```bash
godo find '*.go' # glob expands
```

Use it deliberately: a `:raw` placeholder fed untrusted input is a shell
injection.

## Forms

Inside a body, `${godo:…}` only:

| Form | Behavior |
|------|----------|
| `${name}` | Capture from a matcher key |
| `${godo:args}` | Remaining tokens after the match, space-joined |
| `${godo:args[i]}` | One token; **error** if out of range |
| `${godo:args[i..j]}` | Half-open slice `[i, j)` (Go semantics) |
| `${godo:argv[NAME]}` | Capture bound by the matcher key; quoted |
| `${godo:args}` | Remaining tokens after the match, each quoted, space-joined |
| `${godo:args[i]}` | One token, quoted; **error** if out of range |
| `${godo:args[i..j]}` | Half-open slice `[i, j)` (Go semantics), quoted |
| `${godo:…:raw}` | Any of the above, interpolated verbatim |
| anything else `${…}` | Not godo's — passed to the shell untouched |

In a matcher key or a `@deps` entry, bare `${NAME}` only. `${godo:…}` is rejected
there: no shell is involved, so there is nothing to disambiguate from.

Capture names must match `[A-Za-z_][A-Za-z0-9_]*`.

## Fail closed

Unknown or malformed `${…}` is an error — no silent empty string.
An unknown or malformed `${godo:…}` is an error — no silent empty string.

```yaml
scripts:
bad: echo ${nope}
bad: echo ${godo:argv[nope]}
```

```bash
Expand All @@ -67,9 +148,11 @@ godo --preview bad
**Errors:**

```text
godo: unknown capture ${nope}
godo: unknown capture "nope" (not bound by the matcher key)
```

Bare `${nope}` is **not** an error: it is not godo's to judge.

## Next

- [Matcher dialect](./matcher-dialect.md)
Expand Down
Loading
Loading