Skip to content

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.

KindPrecedenceThe directory holds
workspace1000a nested workspace, with its own chant.workspace.json or .jsonc
chant500a chant project, with chant.config.ts or chant.config.json directly in it
designnever claims a directorythe design artifacts the workspace owns, as data. chant reads the files and builds nothing
othernever claims a directoryanything chant does not read. The member needs because
examplesnever claims a directorythe 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.

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.

{
"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"] }
}
]
}
FieldValue
schema1
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[].descriptionOne line saying what the directory holds, shown in listings and messages
kinds[].precedenceAn integer from 1 to 999
kinds[].probe.anyFileFile 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.anyBlockin, 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.anyJsonKeyin, 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[].outputsOptional. 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[].graphOptional. How chant workspace graph reads a member of the kind. See below
kinds[].fieldsOptional. 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.

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_]*$" } }
}
}
}
FieldValue
fields.<name>A name matching ^[a-z][A-Za-z0-9]{0,39}$
fields.<name>.typestring, integer, boolean or object
fields.<name>.descriptionOne line saying what the field holds
fields.<name>.defaultOptional, 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>.patternOptional, for a string field. A regular expression the value must match
fields.<name>.propertiesFor 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.

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}" } }
}
}
}
FieldValue
graph.lexiconThe 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.configThe 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.

Kinds come only from the declaration’s pins. chant does not look at other installed packages.

PinWhere 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.

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" }
]
}
FieldValue
schema1, 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[].descriptionOne line saying who is in the class
classes[].roleThe 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.

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" }] }
]
}
PartValue
Precedence100, below chant, so a chant project with a start script stays chant
ProbeanyJsonKey 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
Outputssource, the directory a delivery member builds from, and url, where the running app answers
fields.scriptsWhich 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.envThe variables the app reads, by name: port (PORT), data (APP_DATA) and revision (APP_REVISION)
fields.healthThe 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.

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.

KindPackagePrecedenceProbe
choudoufu@intentius/chant-lexicon-terraform450anyFile: ["estate.chdf.hcl"], or anyBlock for a live block in a *.tf file
terraform@intentius/chant-lexicon-terraform400anyFile: ["*.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.

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.

SituationResult
One kind has the highest precedence among those that claim the directoryThat kind is the directory’s kind. A member declared as another kind fails with WSP007
Two or more claiming kinds share the highest precedenceA tie. The member fails with WSP006, and one of the kinds needs a different precedence
No kind’s probe claims the directoryOnly other fits

The root member "." always holds the workspace’s own declaration, so the workspace kind never claims it.

@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.