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 ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Long-term, Boutiques shifts from being the primary frontend to primarily a **bac
- **Boutiques** - remains as both a frontend and a backend
- **Serialized Python argparse** (`argdump`) - implemented
- **Connectome Workbench** (`workbench`) - implemented; covers NiWrap's `wb_command` suite
- **Custom TypeScript-types-like language** (planned) - the intended primary way to define CLI specs
- **argtype** (`argtype`) - implemented as both a frontend and a backend; the hand-authored, TypeScript-types-like DSL (see the [argtype spec](https://nx10.dev/argtype/)) intended as the primary way to define CLI specs. Covers the core grammar plus the `outputs`, `mediatypes`, and `paths` (`.mutable()` / `.resolveParent()`) extensions; `set` lowers to a sequence, `any` to its first branch, and the draft `constraints` extension is parsed-and-ignored. Frontend lives in `frontend/argtype/`, backend (IR -> argtype source, the round-trip/dogfooding path) in `backend/argtype/`

## Ecosystem Context

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/backends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ describe("backends", () => {
expect(knownBackends).toContain("python");
expect(knownBackends).toContain("typescript");
expect(knownBackends).toContain("boutiques");
expect(knownBackends).toContain("argtype");
expect(knownBackends).toContain("schema");
});

it("resolves the argtype serialization backend", () => {
const { backends, unknown } = resolveBackends(["argtype"]);
expect(unknown).toEqual([]);
expect(backends.map((b) => b.name)).toEqual(["argtype"]);
});

it("resolves aliases to backend instances", () => {
const { backends, unknown } = resolveBackends(["python", "typescript"]);
expect(unknown).toEqual([]);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/backends.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ArgtypeBackend,
BoutiquesBackend,
JsonSchemaBackend,
NipypeBackend,
Expand All @@ -19,6 +20,7 @@ const registry: Record<string, () => Backend> = {
schema: () => new JsonSchemaBackend(),
"json-schema": () => new JsonSchemaBackend(),
boutiques: () => new BoutiquesBackend(),
argtype: () => new ArgtypeBackend(),
nipype: () => new NipypeBackend(),
pydra: () => new PydraBackend(),
};
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const SUPPORTED_FORMATS: ReadonlySet<string> = new Set<FormatName>([
"argdump",
"workbench",
"mrtrix",
"argtype",
]);

export interface BuildOptions {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/backend/argtype/__fixtures__/microsyntax.argtype
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
exe: "cut"
version: "9.0"
extensions:
- mediatypes
---

/// Cut fields from each line, exercising join micro-syntax and media types
cut: seq(
input: path.mediaType("text/plain"),
seq("--fields=", fields: rep(int).join(",")).join(),
opt("-d", delimiter: str),
defines: rep(seq(str, "=", str).join("")),
out: path.mediaType("application/json").mediaType("text/csv"),
)
25 changes: 25 additions & 0 deletions packages/core/src/backend/argtype/__fixtures__/subcommands.argtype
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
exe: "vcs"
version: "1.2.0"
authors:
- "VCS Authors"
---

/// Mini version control with subcommands
vcs: alt(
/// Record changes to the repository
commit: seq(
"commit",
opt("-m", message: str),
opt("--amend"),
opt("--author", author: str),
),
/// Publish local commits to a remote
push: seq(
"push",
opt("-u", upstream: str),
opt("--force"),
remote: str = "origin",
branch: str,
),
)
45 changes: 45 additions & 0 deletions packages/core/src/backend/argtype/argtype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { CodegenContext } from "../../manifest/index.js";
import type { Backend, EmittedApp } from "../backend.js";
import { Scope } from "../scope.js";
import { snakeCase } from "../string-case.js";
import { generateArgtype } from "./emit.js";

/**
* A per-tool file stem, so co-located tools in a catalog build don't clobber one
* another's `descriptor.argtype`. Standalone single-tool builds (no scope) keep
* the bare name. Mirrors the JSON Schema backend's `schemaStem`.
*/
function argtypeStem(ctx: CodegenContext, scope?: Scope): string | undefined {
const id = ctx.app?.id;
if (!id || !scope) return undefined;
return scope.add(snakeCase(id) || "descriptor");
}

/**
* Serialization backend: emit argtype sugar-DSL source from the IR + `AppMeta`.
*
* The dogfooding / round-trip counterpart to the argtype frontend. Like the
* Boutiques backend it only needs the IR (`ctx.expr`) and app metadata
* (`ctx.app`), never the solved bindings, so it ignores the rest of the context.
*/
export class ArgtypeBackend implements Backend {
readonly name = "argtype";
readonly target = "argtype";

/** One scope per package so per-tool file stems stay unique in the suite dir. */
newPackageScope(): Scope {
return new Scope();
}

emitApp(ctx: CodegenContext, scope?: Scope): EmittedApp {
const { source, warnings } = generateArgtype(ctx.expr, ctx.app);
const stem = argtypeStem(ctx, scope);
const filename = stem ? `${stem}.argtype` : "descriptor.argtype";
return {
meta: ctx.app,
files: new Map([[filename, source]]),
errors: [],
warnings,
};
}
}
53 changes: 53 additions & 0 deletions packages/core/src/backend/argtype/corpus-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { compile } from "../../index.js";
import { ArgtypeParser } from "../../frontend/argtype/parser-frontend.js";
import { generateArgtype } from "./emit.js";

/**
* Corpus validity guard: for every descriptor in the typecheck catalog (the
* curated hard cases - unions, dup outputs/variants, mutable inputs, mrtrix,
* workbench), the emitter must produce argtype source that re-parses with ZERO
* errors. This is the strongest property that holds universally: it proves the
* backend never emits invalid/non-reparseable syntax across the shape diversity
* of real tools, independent of the documented annotation lossiness (docs,
* titles, media types, mutable) and the frontend's outputs-collected-to-root
* behavior, which prevent exact codegen equality for some tools.
*/

const CATALOG = new URL(
"../../../../../packages/cli/test-fixtures/typecheck-catalog/suite/1.0/",
import.meta.url,
);

const TOOLS: Array<[string, string]> = [
["DenoiseImage", "boutiques.json"],
["antsApplyTransforms", "boutiques.json"],
["borderMerge", "workbench.json"],
["dupOutputs", "boutiques.json"],
["dupVariant", "boutiques.json"],
["dwi2response", "boutiques.json"],
["mrtrixDemo", "mrtrix.json"],
["mutate", "boutiques.json"],
["shapes", "boutiques.json"],
["variousTypes", "boutiques.json"],
];

const argtype = new ArgtypeParser();

describe("argtype backend: corpus emits re-parseable source", () => {
for (const [tool, file] of TOOLS) {
it(`${tool}: emitted argtype re-parses cleanly`, () => {
const source = readFileSync(fileURLToPath(new URL(`${tool}/${file}`, CATALOG)), "utf8");
const direct = compile(source);
expect(direct.errors, `${tool}: direct parse errors`).toEqual([]);

const { source: emitted } = generateArgtype(direct.expr, direct.meta);
const reparsed = argtype.parse(emitted);
expect(reparsed.errors, `${tool}: emitted argtype failed to re-parse:\n${emitted}`).toEqual(
[],
);
});
}
});
Loading