Workspace Kinds
A member’s kind in chant.workspace.json says how chant reads its directory (#2524 D3). The list of kinds is closed, since each one is either built in or supplied by a package the declaration pins, and a member of any other kind fails chant workspace check with WSP003, which lists the known kinds.
Kinds are plain data. Besides its name and description, each kind carries a probe that looks for files in the member’s directory and a precedence that settles overlaps between probes. Probes run no code, and reading a package’s kinds never imports the package, so listing a workspace never loads a lexicon.
Built-in kinds
Section titled “Built-in kinds”| Kind | Precedence | The directory holds |
|---|---|---|
workspace | 1000 | a nested workspace, with its own chant.workspace.json or .jsonc |
chant | 500 | a chant project, with chant.config.ts or chant.config.json directly in it |
design | never claims a directory | the design artifacts the workspace owns, as data. chant reads the files and builds nothing |
other | never claims a directory | anything chant does not read. The member needs because |
examples | never claims a directory | the kind of an example group, not of a member |
Each kind also says which outputs its members expose to member links. A chant member exposes the outputs its source declares, a nested workspace exposes none, and an other or design member exposes what its entry lists.
A design member is the data member of D18 (#2549, ws-062). It needs no because, and WSP009 does not report it. Its probe, like the one of other, only checks that the directory exists. Records pin its files by hash, chant workspace graph --intent shows them, and the per-member commands leave it out with kind-not-run. A declaration that uses the kind needs chant 0.101.0, since an older chant lists the member with unknown-kind.
The probe of other only checks that the directory exists, and it never claims a directory from another kind. chant workspace check reports every other member as a WSP009 warning, and chant doctor shows the same warning. The check fails with WSP008 when a registered kind’s probe claims an other member’s directory, since that member should be declared as the kind that claims it.
Kinds from a package
Section titled “Kinds from a package”A package supplies kinds by exporting a JSON file at its ./workspace-kinds subpath. This follows the slim ./detect entry every lexicon exports (#426), with one difference. The target is a data file, not a module.
{ "exports": { ".": "./src/index.ts", "./workspace-kinds": "./workspace-kinds.json" }, "files": ["src/", "dist/", "workspace-kinds.json"]}The subpath may also be a conditions object, in which case chant reads its default condition. A lexicon can export the subpath from its own package, or a kinds-only package can carry nothing but package.json and the kinds file.
chant reads the key ./workspace-kinds literally. A ./* pattern in exports maps the subpath to code, so chant ignores it. A target that isn’t a .json file inside the package is refused and never run.
The kinds file
Section titled “The kinds file”{ "schema": 1, "kinds": [ { "name": "terraform", "description": "a Terraform root module, with a main.tf in its directory", "precedence": 400, "probe": { "anyFile": ["main.tf", "versions.tf"] } } ]}| Field | Value |
|---|---|
schema | 1 |
kinds[].name | ^[a-z][a-z0-9-]{0,39}$. It can’t be chant, workspace, other or examples, and a package lists each name once |
kinds[].description | One line saying what the directory holds, shown in listings and messages |
kinds[].precedence | An integer from 1 to 999 |
kinds[].probe.anyFile | File names. The probe passes when a file matching one of them sits directly in the member’s directory. A * matches any run of characters in a name, so *.tf matches every .tf file |
kinds[].probe.anyBlock | in, file names as in anyFile, and blocks, block names. The probe passes when a file directly in the directory whose name matches in has a line that opens one of those blocks with no label, such as live {. Leading space is allowed, and no file is parsed |
kinds[].probe.anyJsonKey | in, file names as in anyFile, and pointers, JSON Pointers such as /scripts/start. The probe passes when a file directly in the directory whose name matches in parses as JSON and has a value at one of the pointers. A file that isn’t JSON is no evidence either way. Added in chant 0.103.0 (#3151) |
kinds[].outputs | Optional. Output names every member of the kind exposes to member links. A member entry of the kind can list more in its own outputs |
kinds[].graph | Optional. How chant workspace graph reads a member of the kind. See below |
kinds[].fields | Optional. The fields a member of the kind may set, each with a type and a default. See Fields. Added in chant 0.103.0 |
A probe needs at least one of anyFile, anyBlock and anyJsonKey, and it passes when any one does. All three only look at files directly in the directory, never below it.
Fields whose names start with x- are allowed on the file and on each kind. Anything else is refused. The JSON Schema ships in @intentius/chant at src/workspace/workspace-kinds.schema.json, with the $id https://intentius.io/chant/schemas/workspace/kinds/v1/workspace-kinds.schema.json.
Fields
Section titled “Fields”A kind can declare the conventions its members follow as fields, each with a default, so a member states only where it differs and a reader such as an orchestrator gets every value from the read contract (#3151, ws-093).
{ "name": "svc", "fields": { "port": { "type": "integer", "description": "The port it listens on", "default": 8080 }, "env": { "type": "object", "description": "The variables it reads", "properties": { "data": { "type": "string", "description": "Its data directory", "default": "DATA_DIR", "pattern": "^[A-Z_][A-Z0-9_]*$" } } } }}| Field | Value |
|---|---|
fields.<name> | A name matching ^[a-z][A-Za-z0-9]{0,39}$ |
fields.<name>.type | string, integer, boolean or object |
fields.<name>.description | One line saying what the field holds |
fields.<name>.default | Optional, for a string, integer or boolean field. The value a member that doesn’t set the field gets; without it the field reads as null |
fields.<name>.pattern | Optional, for a string field. A regular expression the value must match |
fields.<name>.properties | For an object field, its own string, integer or boolean fields, each declared the same way |
A default of the wrong type, or a pattern that isn’t a regular expression, is a WSP002 problem. A member entry sets values under fields, and chant workspace check fails a field the kind doesn’t declare, or a value of the wrong type or pattern, with WSP005. chant workspace status --json prints members[].fields with every declared field, the entry’s value or the default, and null for a member whose kind declares no fields.
Reading a member in the graph
Section titled “Reading a member in the graph”A kind’s members are directories chant can probe, but a directory of .tf files has no chant config, so there is nothing for chant graph to run in it. The optional graph block says how to read one (#2874):
{ "name": "terraform", "graph": { "lexicon": "terraform", "config": { "moduleRoot": "{workspace}", "roots": { "{member}": { "dir": "{dir}" } } } }}| Field | Value |
|---|---|
graph.lexicon | The lexicon that reads the member, by name. The package that supplies the kind must be @intentius/chant-lexicon-<lexicon>, so the lexicon chant loads is the one the declaration pins. A kind whose package has another name keeps its probe and loses its graph block, with a WSP002 problem |
graph.config | The lexicon’s config namespace for the reader project. In every string key and value, {member} becomes the member’s name, {dir} its absolute directory and {workspace} the absolute workspace root. Nothing else is interpreted |
For each member, chant workspace graph writes a reader project in a temporary directory: a chant.config.json with lexicons: [<lexicon>] and config under the lexicon’s key, and a node_modules link to the directory that holds the pinned package. The member’s chant graph --format ir runs there, and the project is removed afterwards. Nothing is written under the workspace. The block is data like the rest of the file: listing a workspace still imports nothing, and only graph loads the lexicon.
Which packages are read
Section titled “Which packages are read”Kinds come only from the declaration’s pins. chant does not look at other installed packages.
| Pin | Where chant looks |
|---|---|
{ "package": "...", "version": "..." } | node_modules/<package> in the workspace root or the nearest directory above it that has one, the way Node resolves a bare import. The installed version must equal the pinned one |
{ "path": "..." } | That directory inside the workspace |
A pin that isn’t installed, is installed at another version, or publishes a kinds file that doesn’t validate is a WSP002 error. Its kinds are left out, so members of those kinds also fail with WSP003. A pin on a package with no subpath (@intentius/chant itself, for one) supplies no kinds and is no error. A kind name that two pins both supply is an error, and neither package’s version of it is used.
chant workspace ls --at <rev> reads kinds from the packages installed in the working tree, not from the revision.
Principal classes from a package
Section titled “Principal classes from a package”A package can also name principal classes for the declaration’s writeScope (#2524 D5, #3080, ws-079). The four core classes are human, agent, runner and service. A domain class, such as reviewer or operator, is the set of principals holding one role in the trust policy at base (roles in .chant/trust.json). The package exports the classes as a JSON file at its ./workspace-principals subpath, beside or instead of ./workspace-kinds. The subpath follows the rules for kinds above, so chant reads only that literal key and only a .json target inside the package, as data.
{ "exports": { "./workspace-principals": "./workspace-principals.json" }}{ "schema": 1, "classes": [ { "name": "reviewer", "description": "people who review decisions", "role": "reviewer" } ]}| Field | Value |
|---|---|
schema | 1, the version of this format |
classes[].name | ^[a-z][a-z0-9-]{0,39}$, not starting x-. It can’t be a core class, and a package lists each name once |
classes[].description | One line saying who is in the class |
classes[].role | The role whose grant at base puts a principal in the class. It can’t be agent, runner or service, which put a principal in the core class of that name, and one role belongs to one class |
Its schema is src/workspace/workspace-principals.schema.json in @intentius/chant ($id https://intentius.io/chant/schemas/workspace/principals/v1/workspace-principals.schema.json), and x- fields are allowed on the file and on each class.
Classes are read from the declaration’s pins the way kinds are, with one difference: write scope reads a path-pinned package at the base revision, beside the declaration and the trust policy, so a change can’t remap a class by editing its own copy of the plugin. A package pin is read from the installed package, which must be at the version the base pins. A file that doesn’t validate is a WSP002 error, and a class name or a role two pins both supply is an error that leaves every class involved out.
The core roles are tried first, in the order agent, runner, service. The domain classes follow in pin order, and within a package in file order. The first class whose role the principal holds is the one write scope judges it by, and anyone else is human. A writeScope key no pinned package supplies fails chant workspace check with WSP003. Nothing then says who is in that class, so chant refuses every write judged human with write-scope-class-unknown until the package is installed or the entry removed.
The app kind
Section titled “The app kind”chant ships an app member kind as a data-only kinds file in @intentius/chant, at src/workspace/reference-kinds/app (#3151, ws-093). It is a directory with a package.json that exports ./workspace-kinds and the kinds file. A workspace copies the directory and pins the copy by path, as the reference workspace does at kinds/app, so only a workspace that asks for apps has the kind.
{ "pins": [{ "path": "kinds/app" }], "members": [ { "name": "app", "dir": "app", "kind": "app", "fields": { "health": "/healthz" } }, { "name": "delivery", "dir": "delivery", "kind": "chant", "links": [{ "member": "app", "output": "source" }] } ]}| Part | Value |
|---|---|
| Precedence | 100, below chant, so a chant project with a start script stays chant |
| Probe | anyJsonKey for /scripts/start in package.json: a Node package with a start script. A package with scripts but no start, such as a repository root, is not claimed |
| Outputs | source, the directory a delivery member builds from, and url, where the running app answers |
fields.scripts | Which package.json script plays each part: start (default start), dev (dev), test (test) and migrate (migrate). A script that doesn’t exist means the app has none, such as no migrations |
fields.env | The variables the app reads, by name: port (PORT), data (APP_DATA) and revision (APP_REVISION) |
fields.health | The path the running app answers on once it is up, default /health |
An orchestrator reads these from status --json rather than assuming them: it runs npm run <scripts.start> with the port in the variable env.port names and waits for health to answer. Once a workspace pins the kind, WSP008 fails any other member that is a Node package with a start script. Declare that member as an app.
Terraform and choudoufu
Section titled “Terraform and choudoufu”Two kinds ship from the terraform lexicon, since a choudoufu estate is a Terraform root that the lexicon’s choudoufu mode reads. behold used to keep both in its closed list of member kinds (#2545), and each keeps behold’s probe, narrowed to the member’s own directory, and behold’s order: a chant project first, then choudoufu, then terraform.
| Kind | Package | Precedence | Probe |
|---|---|---|---|
choudoufu | @intentius/chant-lexicon-terraform | 450 | anyFile: ["estate.chdf.hcl"], or anyBlock for a live block in a *.tf file |
terraform | @intentius/chant-lexicon-terraform | 400 | anyFile: ["*.tf"] |
A choudoufu root also passes the terraform probe, and the higher precedence makes it a choudoufu member. A workspace that uses either kind pins the lexicon.
Both kinds carry a graph block, so chant workspace graph reads their members through the lexicon. Each member becomes one root named after the member, so a resource’s id is <member>/<member>/<address>, and a local module’s is <member>/<member>/module.<name>/<address>. The block sets the lexicon’s moduleRoot to the workspace root, so a root that calls ../modules/x keeps its modules as long as they sit inside the workspace. The choudoufu block also sets binary: "choudoufu", so the estate the root declares is read as a live root.
{ "pins": [ { "package": "@intentius/chant-lexicon-terraform", "version": "0.81.0" } ], "members": [ { "name": "network", "dir": "estates/network", "kind": "terraform" }, { "name": "prod", "dir": "estates/prod", "kind": "choudoufu" } ]}The chant repo pins both by path and declares examples/terraform-carve-out/terraform as a terraform member.
Overlapping probes
Section titled “Overlapping probes”Several kinds can claim one directory. A chant project that also holds a main.tf passes the probes of chant and terraform. The highest precedence decides.
| Situation | Result |
|---|---|
| One kind has the highest precedence among those that claim the directory | That kind is the directory’s kind. A member declared as another kind fails with WSP007 |
| Two or more claiming kinds share the highest precedence | A tie. The member fails with WSP006, and one of the kinds needs a different precedence |
| No kind’s probe claims the directory | Only other fits |
The root member "." always holds the workspace’s own declaration, so the workspace kind never claims it.
Testing a package’s kinds
Section titled “Testing a package’s kinds”@intentius/chant-test-utils exports describeWorkspaceKindConformance. It checks the export and the file, runs each kind’s probe over directories you describe, and fails on a tie.
import { describeWorkspaceKindConformance } from "@intentius/chant-test-utils";
describeWorkspaceKindConformance({ packageDir: import.meta.dirname + "/..", scenarios: [ { name: "a root module", kind: "terraform", files: { "main.tf": "" }, claims: true }, { name: "a nested module only", kind: "terraform", files: { "modules/net/main.tf": "" }, claims: false }, ],});Every kind needs one scenario it claims and one it does not. For a lexicon, chant dev check-lexicon also checks that any ./workspace-kinds subpath holds valid kind data that ships in the package.