rmb32 / concept-manager
Concepts, kinds and the requirements between them
Requires
- php: ^8.5
- ext-ds: ^2.0
- php-ds/php-ds: ^2.0
- rmb32/typed-json: ^1.0
Requires (Dev)
- deptrac/deptrac: ^4.7
- infection/infection: ^0.35.8
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^13.4
- rector/rector: ^2.7
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-24 11:37:14 UTC
README
Concept Manager holds concepts, the kinds that describe them fully, and the requirements between them. A named place (the bed in a bedroom) is kept apart from whatever fills it, so one kind can fill many places by reference, and everything below a concept keeps what is true of it.
What it needs
PHP 8.5, the ds extension (v2), and rmb32/typed-json, which reads files and brings
cuyz/valinor with it. Composer installs both.
Installation
composer require rmb32/concept-manager
2.0 changes Instance, which 1.x laid out only from a kind: Instance::of() now takes a concept
too, and lays out its outline, so kind() may be null. main is aliased 2.x-dev.
The words
- Headspace: the concepts and kinds about one subject, built whole from declarations. It never changes: an edit is a rebuild.
- Concept: an idea that may still ask for things. It can extend one other concept.
- Kind: a description of a concept with nothing left open. A kind is never an individual: the same kind at two places is two instances of one description, not one thing in two places. Nothing extends a kind.
- Requirement: a named place a concept asks to have filled, by one kind of some concept or by many. It is unmet until kinds are given, and met afterwards.
- Range: how many kinds a requirement takes. A requirement for one takes exactly one. A
requirement for many takes any number unless it says otherwise: at least some (
atLeast), at most some (atMost), and perhaps no kind twice (distinct). - Part: a requirement for one that is always there, held by a concept. It is open while its concept leaves anything unmet, and met once everything in it is, by a kind of that concept with the part's name and meaning. A part can hold parts of its own.
- Meaning: what a headspace, concept, kind or requirement is about, in words. Each has a name
and a meaning. Names are free text (
Living room). - requires: adds an unmet requirement.
- provides: adds a requirement that is met from the start, or a part.
- resolves: meets an inherited requirement.
- within: meets and narrows requirements inside an inherited part, in place.
- narrows: makes an inherited unmet requirement ask for a concept that is or extends the one it asked for, tightens the range of a requirement for many, or both.
- Inline kind: a kind declared in place, wherever a declaration names a kind. It has its own name and meaning but is not registered: its name needn't be unique, and nothing else can use it.
- Inline concept: a concept declared in place, wherever a declaration names a concept. Like an inline kind it is not registered.
- Kind that fills: an inline kind of whatever concept the requirement it meets asks for, so it names no concept itself.
- Local: a concept or kind that only the top-level declaration holding it can name, written once and used wherever that declaration needs it.
- Metadata: what is said of a concept, kind or requirement that Concept Manager carries and never reads: keys, each with a bool, int, float or string.
- Instance: one occurrence of a kind at one place in a tree of kinds, or, in an outline, of the concept itself or of a part it leaves open.
- Outline: a concept laid out before anything is chosen: every part, open or met, and every met requirement's kinds.
- Open place: a requirement an outline leaves unmet. Nothing is laid out beneath it, since that depends on what fills it.
- Entry point: a kind no declaration in the headspace names, where reading it starts.
- Supplied metadata: metadata given from outside a headspace at a path in a tree, for whoever reads the tree. Concept Manager reads none of it.
- Declaration file: one top-level declaration, written as JSON, with the headspace it belongs to and perhaps a group.
- Group: a heading within a headspace that gathers related declarations. It changes nothing about what is declared.
- Source: a folder of declaration files, with a
headspaces.jsonsaying which headspaces and groups it describes. A shipped source is read-only; your own can be written to. - Registry: declarations over an ordered list of sources, in which nothing shadows anything.
- Status: how a declaration stands once its headspace is built: faulty, open at the places its outline leaves open, or complete.
- Impact: what a change would do to the statuses of the declarations it reaches, found before it is written.
- Projection: a tree or an outline turned into a file, or a folder of files, for whoever will read it: a tree of names, a document, folders or a diagram.
The principle
Whatever is true of a concept stays true of everything that extends it and of every kind of it, and a kind leaves nothing open.
The rules
- Every name in a headspace is unique across concepts and kinds; a later declaration of a taken name is a fault. Declarations may come in any order. No kind may hold itself and no concept may extend itself, directly or through others; a concept may ask for itself, directly or through others. Inside a top-level declaration, which includes what is written inside it and its locals, a name means one of its locals first and the headspace's after. Its locals' names are unique among them and differ from its own.
- A concept inherits every requirement and all the metadata of the concept it extends, and a kind all the metadata of its concept. Either may add a metadata key and never changes one.
- A requirement name is unique within its lineage and within one declaration.
resolvesandnarrowsname an inherited requirement, andwithinan inherited part. - A requirement is met only by kinds of the concept it asks for, or of a concept extending it. It is narrowed only to a concept that is or extends the one it asks for, and its range only tightened: no fewer at least, no more at most, and no kind twice once it says so. Only a requirement for many has a range to tighten.
- A requirement for one is met by exactly one kind. A requirement for many is met by a list as long as its range allows; unless it says otherwise, the list may be empty or repeat a kind.
- Once met, a requirement stays met.
- A kind leaves nothing unmet, including what it inherits from every ancestor and anything inside a part.
- Every concept, kind, requirement and headspace has a name and a meaning, and neither is blank.
- Every concept can be met: it, or a concept that extends it, can have a kind. A concept that asks
for one of itself, directly or through others, can never be met, since each kind of it would
need another without end. A requirement for many ends wherever its range allows an empty list,
and a requirement whose range takes nothing (
atLeast: 2, atMost: 1) can never be met. - Every local is named by the declaration that holds it, directly or through its other locals.
Where each rule comes from
- Unique names, local names and rule 3: bookkeeping, since a name picks out one thing. A local is a name its holder keeps to itself, so it may be one the headspace also has.
- A loop must pass through asking: a loop of
extends, kinds,resolvesandprovidesalone never ends or makes a concept its own ancestor; asking only describes. A folder that holds folders is sound, and so is a kind of it: it names the folders inside it, and they end. Holding is not asking: a part is always there, so a concept that holds itself as a part, directly or through others, never ends. - Rules 2, 4, 5 and 6: the principle. A concept's requirements, what each asks for, how many it takes, what met it and its metadata all stay true below it. A range that widened below would let through what the concept above refused. Metadata is true of what carries it, so a key set on a concept is set on everything below it. A requirement's metadata is the place's, and the place keeps it whatever meets, narrows or counts it.
- No kind twice is something a requirement says, not a rule. A kind is a description, so four
legs of one
Legkind are four instances, and a list may repeat a kind unless its requirement says otherwise. - Rule 7: what a kind is.
- Rules 8, 9 and 10: policies, the only three. Rule 9 does not follow from the principle: a concept nobody can make a kind of contradicts nothing. It reports the mistake once, at the concept, rather than at every kind someone tries to declare of it. Rule 10 reports a local that is never used, as Barncept refuses a thought its root does not reach.
Breaking a rule gives a Rmb32\ConceptManager\Concept\Exception\ConceptManagerException that
names it: NameTaken, UnknownConcept, UnknownKind, RequirementNameTaken, NoSuchRequirement,
KindDoesNotFit, ConceptDoesNotFit, WrongNumberOfKinds, RepeatsAKind, WidensACount,
NotARequirementForMany, NegativeCount, RequirementAlreadyMet, RequirementsLeftUnmet,
NotAPart, FillsNothing, BlankName, BlankMeaning, CanNeverBeMet, DeclaredInALoop,
DependsOnAFault, LocalNeverNamed, MetadataChanged, NotTopLevel or BlankKey. A fault at
a requirement inside a part names it by its path from the declaration, as in
Ground floor/Kitchen.
Declaring
A declaration is plain data that refers to other things by name:
ConceptDeclaration::new($name, $meaning, $requires, $provides, $locals, $metadata)
ConceptDeclaration::extending($name, $meaning, $parent, $resolves, $narrows, $requires, $provides, $locals, $metadata)
KindDeclaration::of($name, $meaning, $concept, $resolves, $provides, $locals, $metadata)
KindDeclaration::standalone($name, $meaning, $provides, $locals, $metadata)
KindDeclaration::filling($name, $meaning, $resolves, $provides, $metadata)
Requires::one($name, $meaning, $concept, $metadata)
Requires::many($name, $meaning, $concept, $atLeast = 0, $atMost = null, $distinct = false, $metadata)
Provides::one($name, $meaning, $kind, $metadata)
Provides::many($name, $meaning, $kinds, $metadata)
Provides::part($name, $meaning, $requires, $provides, $metadata)
Provides::partOf($name, $meaning, $concept, $metadata)
Resolves::to($requirement, $kindOrKinds)
Narrows::to($requirement, $concept = null, $atLeast = null, $atMost = null, $distinct = null)
Resolves::within($part, $resolves, $narrows)
resolves, narrows, requires and provides are each a list of these records, never an array
keyed by name, so a requirement may be named with digits only (12). Every factory takes them in
the order an extension applies them: resolves, narrows, requires, provides. A requirement named
twice in one declaration is never lost: a second Resolves finds it met
(RequirementAlreadyMet), and a second Narrows applies on top of the first, so it must fit it.
A requirement says whether it takes one kind or many. In Provides and Resolves, a single kind
or a list gives the kinds in order, and the requirement decides how many it takes: resolving a
requirement for many with a single kind gives it a list of one.
A requirement for many may bound what it takes: Requires::many('Garage', …, atMost: 1) is "at
most one garage", and atLeast: 1, atMost: 7 is one to seven. distinct: true takes no kind twice
(RepeatsAKind). A list outside the range is WrongNumberOfKinds, which names the range. A
Narrows may tighten a range, with or without a concept, and whatever it does not give stays as it
is: Narrows::to('Aggregates', atMost: 1). A narrowing that would widen a range, or take back
distinct, is WidensACount. A count on a requirement for one is NotARequirementForMany, and a
negative bound throws NegativeCount when it is written.
Wherever a kind is named, a
KindDeclaration may stand in its place as an inline kind. A standalone kind has a concept of its
own, of the same name and meaning, that nothing else can ask for.
Wherever a concept is named, a ConceptDeclaration may stand in its place as an inline concept: the
concept a requirement asks for, the one a narrowing narrows to, and the one a part is held by.
Nothing else can name it, and a fault inside it is the fault of the declaration that holds it. A
kind that fills (KindDeclaration::filling) is how a kind of an inline concept is written; it may
stand only in a Resolves::to, and anywhere else, provides or the headspace itself, it gets
FillsNothing.
Provides::part adds a part with a concept of its own, of the same name and meaning, as a
standalone kind has; Provides::partOf adds a part held by a named or inline concept. A part
nothing is left open in is met at once. Resolves::within meets and narrows what an inherited part
holds, and a within among its resolves reaches a part within that part. In a concept, whatever it
leaves open stays open in the part; in a kind, everything in every part must end up met. The part
then holds an unregistered extension of its concept, of the part's name and meaning, until nothing
in it is open. A part can also be met whole, with Resolves::to and a kind of its concept, or
narrowed. Naming anything but a part in a within is NotAPart. An extension cannot add a
requirement inside an inherited part.
locals: holds concepts and kinds that only the declaration holding them can name, as a list of
declarations. A thought wanted under several parents is written once as a local and named at each
place, as Library's Exceptions is below. Inside the holder, a local's name hides the headspace's.
Only a declaration passed to the headspace holds locals: one written inside another, or a local,
that holds any throws NotTopLevel where it is written. Two locals of one name, or a local named
like its holder, is NameTaken, and a local that neither the holder nor its other locals name is
LocalNeverNamed.
metadata: comes last everywhere, as an array from a key to a bool, int, float or string, which
Concept Manager never reads; a blank key throws BlankKey where it is written. A declaration's
metadata is its concept's or kind's, and an extension or a kind of a concept inherits it and may add
keys. Changing one is MetadataChanged, which names the key, both values, and where each was set.
On Requires and Provides it is the place's: a part's metadata is the requirement's, not its
concept's, and the requirement keeps it when it is met, narrowed, counted or met within. Resolves
and Narrows carry none.
Headspace::build($name, $meaning, ...$declarations) builds every declaration it can, in an order
that puts each after the things it is built from: the concept it extends, the concept of a kind,
every kind it names and every concept a part is held by. A concept it only asks for, through
requires or narrows, may come later or be itself; a declaration's asksFor() lists those names,
and references() every name it uses, including those its inline declarations use. A kind that
fills, or a narrowed part, needs a concept its declaration does not name; the headspace builds that
concept first, and a declaration that needs itself that way is DeclaredInALoop. A
declaration's references() and asksFor() include what its locals use outside it, so the
headspace orders a declaration and its locals as one; its locals are built with it, each after what
it is built from, and it waits for whatever they are built from. Then
concept($name), kind($name) and kindsOf($conceptName) read it. concepts() and kinds()
list the concepts and kinds it holds by name, and kindsOf() the names of a concept's kinds, all in
the order they were declared. Inline kinds, the concepts of standalone kinds, locals and
declarations with a fault are not listed.
The model underneath (Concept, Kind, UnmetRequirement, MetRequirement) can be used
directly; each factory takes a name and a meaning first and throws when a rule is broken.
Resolution::of($requirement, ...$kinds), Resolution::within($part, $resolves, $narrows) and
Narrowing::of($requirement, $concept, $atLeast, $atMost, $distinct) are its Resolves and
Narrows, UnmetRequirement::many($name, $meaning, $concept, $range) takes a Range, and Concept::extending
and Kind::of take them in the same order as the declarations do. UnmetRequirement::part($name,
$meaning, $concept) makes a part, open or met, which provides takes beside met requirements, and
Filling::of($name, $meaning, $resolves, $provides) is a kind that fills, which a Resolution
takes beside kinds. Every requirement says whether it isPart(). Metadata::of($values) is
metadata, with has($key), get($key) and keys(); Concept::new, Concept::extending,
Kind::of, Kind::standalone and Filling::of take it last, and a requirement takes it with
carrying($metadata). Each of them has its metadata(), a concept's and a kind's including
what they inherit.
Faults
Building a headspace never stops at the first problem. Each declaration that cannot be built gives
one fault, faults(), holding the declaration's name and its ConceptManagerException, in the
order the declarations were given. Everything else is built.
- A later declaration of a taken name is dropped with
NameTaken. - Every declaration in a loop that does not pass through asking gets
DeclaredInALoop, naming the whole loop. - A concept that can never be met gets
CanNeverBeMet, naming its requirements that can never be met: those that take at least one kind of a concept that can never be met, and those whose range takes nothing (Concept Folder can never be met: Parent). - A declaration that refers to one with a fault is not built, or is taken out of the headspace
once the fault is found: it gets
DependsOnAFault. A declaration that only asks for one with a fault keeps a fault of its own, if it has one. - A fault inside an inline kind, an inline concept or a local is the fault of the declaration that
holds it, and carries its own exception (
RequirementsLeftUnmetnames the inline kind). A loop among a declaration's locals isDeclaredInALoopon the declaration, naming the locals, and a local that can never be met makes its holderCanNeverBeMet, naming the local. - A fault at a requirement inside a part, or inside a kind that fills, names the requirement by its
path from the declaration:
RequirementsLeftUnmeton12 Orchard LanenamesGround floor/Kitchen, andNoSuchRequirementinside the second of two kinds that fillCoursesnamesCourses[1]/Garnish. - A blank headspace name or meaning throws, since there is no declaration to pin it to.
- Looking up a name whose declaration has a fault, with
concept(),kind()orkindsOf(), throwsHasAFault, which carries theFault(Hall has a fault: No concept is named Room). A name never declared givesUnknownConceptorUnknownKind. ADependsOnAFaultnames what it depends on, so looking that up in turn follows a chain of faults to its root.
Instances and outlines
Instance::of($kind) lays a kind out as a tree, and Instance::of($concept) lays a concept out
as its outline. Every part is laid out, open or met, and every met requirement's kinds, one
instance for each kind at each place it fills. An instance's children are its parts, then its met
requirements' kinds, each in requirement order and then list order. A requirement left unmet is
not laid out: it is an open place, listed by open() on the instance it belongs to, with its
requirement(), path() and metadata(). Every instance in an outline has a kind except the
root and the parts it leaves open; a kind's tree has no open places.
An instance knows its kind(), its concept(), the place() it is at (a part, open or met, or
the met requirement it fills), its position() in a requirement for many (from 0) and its
path(): the requirement names from the root, such as Contexts[1]/Domain. Paths are compared
step by step and never parsed, so a name may hold any character; the text form is only for
reading. Its name() and meaning() are the part's at a part, its kind's where it fills a
requirement, and the concept's or kind's it lays out at the root, so a fixed place can be told
from one a kind was chosen for.
descendants() lists every instance below one, depth first, each before its children.
openPlaces() lists every open place in it and beneath it, depth first: an instance's own, then
its children's. at($path) returns the instance, among this one and its descendants, whose path
equals the one given, or throws NoInstanceAt; has($path) says whether an instance or an open
place is there. A path always runs from the root of the tree, so it means the same thing whichever
instance is asked.
Instance::of($root, $supplied) takes metadata from outside the headspace, keyed by path:
SuppliedMetadata::none()->at($path, $metadata). Each value is merged key by key over the
metadata of what an instance is named after, and over an open place's requirement, and the value
supplied wins. At a part, the part's metadata is merged over its concept's first. Paths that name
nothing in the tree are refused together, in one NothingAt listing them all.
$headspace->entryPoints() lists the kinds no declaration in the headspace names, in declaration
order, faulty declarations included: where to start reading it.
Files
A file holds one top-level declaration, with its headspace and, optionally, a group. Its locals and
whatever is written in place are written inside it. Rmb32\ConceptManager\File reads and writes
them, and depends on the engine, never the other way round.
use Rmb32\ConceptManager\File\DeclarationFile;
use Rmb32\ConceptManager\File\Reader;
use Rmb32\ConceptManager\File\Writer;
$file = new Reader()->fromPath('concepts/Homes/Kitchen.json'); // or fromJson($text), fromArray($decoded)
$file->headspace(); // 'Homes'
$file->group(); // 'Indoors', or null
$file->declaration(); // a ConceptDeclaration or a KindDeclaration, for Headspace::build
$json = new Writer()->toJson(DeclarationFile::of('Homes', $declaration, 'Indoors'));
Writer::toJson() writes pretty-printed JSON ending in a newline, with keys in the order the shape
below gives them. It leaves out what says only what is so when it is left out: an empty list, empty
metadata, a group of none, and a range's atLeast of 0, atMost of none and distinct of false.
A part whose concept is written in place with the part's name and meaning, and nothing else, is
written as part. toArray() gives the array that reading that JSON decodes. Reading what was
written gives the declaration that was written. JSON cannot hold an infinite or NaN metadata value,
or text that is not UTF-8: writing one throws Unwritable.
DeclarationFile::of($headspace, $declaration, $group) refuses a headspace, or a top-level name,
that a filesystem would refuse or alter, since each becomes part of the path a file is kept at:
UnsafeName says which and why (because(), an Unsafe). A name is unsafe if it is blank, holds
any of / \ < > : " | ? * or a control character, starts with a dot, or ends with a space or a
dot. Every other name, inside the declaration or of a group, is free text, and a group is refused,
with BlankName, only if it is blank.
The shape
Every factory has exactly one written form, and nothing is inferred from where a record is written:
a kind that fills is written differently from a standalone kind wherever it stands. Wherever a name
may stand, a declaration may be written in place. Counts are written. Every list is a list of
records, never an object keyed by name, so a name may be digits only. A key not marked optional is
required, an unknown key is a fault, and nothing is cast: "1" is not 1.
File
{
"headspace": string, // a name a filesystem takes
"group": string, // optional; not blank
"concept": Concept // or "kind": Kind, exactly one of the two
}
Concept // ConceptDeclaration::new, or ::extending with "extends"
{
"name": string, // not blank; at the top, a name a filesystem takes
"meaning": string, // not blank
"extends": string, // optional; "resolves" and "narrows" only with it
"resolves": [Resolves], // optional
"narrows": [Narrows], // optional
"requires": [Requires], // optional
"provides": [Provides], // optional
"locals": [Local], // optional; at the top only
"metadata": Metadata // optional
}
Kind // exactly one of "of", "standalone" and "fills"
{
"name": string,
"meaning": string,
"of": string, // KindDeclaration::of, naming its concept
"standalone": true, // KindDeclaration::standalone: no "resolves"
"fills": true, // KindDeclaration::filling: no "locals"
"resolves": [Resolves], // optional
"provides": [Provides], // optional
"locals": [Local], // optional; at the top only
"metadata": Metadata // optional
}
Local
{ "concept": Concept } // or { "kind": Kind }, exactly one of the two
Requires // exactly one of "one" and "many"
{
"name": string,
"meaning": string,
"one": string | Concept, // Requires::one
"many": string | Concept, // Requires::many
"atLeast": int, // optional, with "many" only; not negative
"atMost": int, // optional, with "many" only; not negative
"distinct": bool, // optional, with "many" only
"metadata": Metadata // optional
}
Provides // exactly one of "one", "many", "part" and "partOf"
{
"name": string,
"meaning": string,
"one": string | Kind, // Provides::one
"many": [string | Kind], // Provides::many
"part": { // Provides::part, a concept of its own
"requires": [Requires], // optional
"provides": [Provides] // optional
},
"partOf": string | Concept, // Provides::partOf
"metadata": Metadata // optional
}
Resolves // exactly one of "requirement" and "within"
{
"requirement": string, // Resolves::to, with "kinds"
"kinds": [string | Kind],
"within": string, // Resolves::within, naming a part
"resolves": [Resolves], // optional, with "within" only
"narrows": [Narrows] // optional, with "within" only
}
Narrows // Narrows::to; what is left out stays as it is
{
"requirement": string,
"concept": string | Concept, // optional
"atLeast": int, // optional; not negative
"atMost": int, // optional; not negative
"distinct": bool // optional
}
Metadata
{ string: bool | int | float | string } // a key not blank
Handling errors
A file that cannot be read throws NotReadable, and text that is not JSON throws NotJson, which
says where it came from. A file that does not fit the shape throws one NotWellFormed carrying
every fault at once, so an editor can show them all. Each is a typed-json Fault with the dotted
path that caused it (*root* for the file itself), a message and a stable code:
use Rmb32\ConceptManager\File\Exception\NotWellFormed;
try {
$file = new Reader()->fromJson($text);
} catch (NotWellFormed $exception) {
foreach ($exception->faults() as $fault) {
echo $fault->path(), ': ', $fault->message(), ' (', $fault->code(), ')', PHP_EOL;
}
}
concept.requires.0: Exactly one of `one` and `many` is written (not_one_of)
concept.requires.1.colour: Unexpected key `colour`. (unexpected_key)
concept.provides.0.meaning: Value '' is not a valid non-empty string. (invalid_non_empty_string)
typed-json's codes say what does not fit a type: invalid_string, invalid_non_empty_string,
invalid_boolean, invalid_list_key, unexpected_key, cannot_resolve_type_from_union and
others, and a key that is missing is a value of the wrong type. Concept Manager adds its own:
not_one_of and only_with for keys written together that may not be, unsafe_name, and, for
what a declaration refuses where it is written, the exception's name: blank_name,
blank_meaning, blank_key and not_top_level. A record's own fault is found once everything in
it fits, so mending one fault can show another at the record that held it.
A file that fits the shape may still declare something a headspace refuses, such as a kind that does not fit or a name that is taken. That is found when the headspace is built, as a fault on the declaration (see Faults).
Where declarations live
Rmb32\ConceptManager\Store keeps declaration files in folders and finds them again by their
headspace and name, a DeclarationId, not by their path. A name is unique within a headspace,
so Library in Architecture and Library in Homes are two declarations. It depends on the
files and the engine, never the other way round.
One store
FilesystemStore holds one folder. It finds every *.json file beneath it, at any depth, by the
headspace and the concept's or kind's name inside it, reading only those two fields:
use Rmb32\ConceptManager\Store\DeclarationId;
use Rmb32\ConceptManager\Store\FilesystemStore;
$store = FilesystemStore::writable('/path/to/declarations'); // or ::readOnly()
$id = DeclarationId::of('Pets', 'House cat');
$store->ids(); // every DeclarationId it holds, by headspace and then by name
$store->holds($id);
$store->read($id); // the DeclarationFile, read when asked for and kept
$store->write($file); // a new one goes to <headspace>/<name>.json
$store->delete($id);
A declaration that already has a file keeps it when written again; one written under another id is
a new file, and the caller deletes the old one. A read-only store refuses every change with
ReadOnlySource.
What a source describes
Every headspace a declaration names, and every group, must be described: one headspaces.json
at the top of a source says what each means. It is never read as a declaration.
{
"headspaces": [
{ "name": "Pets", "meaning": "Animals kept at home: what kind each is and how it is looked after." }
],
"groups": [
{ "headspace": "Pets", "name": "Cats", "meaning": "Felines kept indoors." }
]
}
Both lists are optional, every meaning must say something, and a source may describe a group in a
headspace another source describes. A store reads it as descriptions(), a HeadspacesFile, and
writes it through describeHeadspace(), describeGroup(), deleteHeadspace() (which takes that
source's groups in it too) and deleteGroup(), each taking a DescribedHeadspace or a
DescribedGroup. A headspaces.json that does not fit is one fault listing every problem in it,
and is left as it is rather than overwritten: changing what that source describes throws
BrokenFile until it is mended. Reader::headspacesFromPath() and Writer::headspacesToJson()
read and write one directly.
A declaration whose headspace, or whose group, no source describes is a fault and is not served.
That turns a misspelt "Petz" into an error rather than a new headspace.
Names that differ only in case are the same name, since a filesystem that ignores case would keep them at one path: two headspaces, two declarations in one headspace, and two groups in one headspace.
Layered sources
A Registry is a composite over an ordered list of sources, of which one at most can be
written to (MoreThanOneWritable otherwise). That is how a shipped, read-only catalogue sits beside
the folder you write to:
use Rmb32\ConceptManager\Store\Registry;
$registry = Registry::over(
FilesystemStore::readOnly('/path/to/vendor/acme/catalogue'),
FilesystemStore::writable('/path/to/my/declarations'),
);
Sources do not shadow one another. The first source to hold a declaration serves it, and the
same id in a later source is a fault on the later file, as two files in one source are. The same
goes for descriptions: a headspace, or a group within a headspace, that a later source describes
again is a fault on that source's headspaces.json. A clash by case alone counts as the same
name.
ids() is every declaration the sources hold, each once. headspaces() and groups($headspace)
are every source's descriptions, by name. get($id) returns the file from the source that serves
it, and throws NotDescribed for one whose headspace or group is not described. origin($id) says
which source serves it, pathOf($id) which file, and isWritable($id) whether it can be changed
where it is, so a reader can label "shipped" against "yours".
put($file) writes to the writable source. It refuses a declaration another source holds, or
holds but for case (AlreadyHeld, naming that source), and one whose headspace or group is not
described; with no writable source it throws NoWritableSource rather than keep it in memory and
lose it. delete($id) refuses what a read-only source holds. describeHeadspace() and
describeGroup() write to the writable source too, and refuse what another source describes
(DescribedTwice); a DescribedHeadspace or DescribedGroup already refuses what a reader of
headspaces.json would, so describing never leaves a file the source cannot read.
deleteHeadspace() and deleteGroup() refuse while a declaration is still in it (InUse, listing
them), and what another source describes (ReadOnlySource).
What the sources hold is gathered afresh on every call, since a source may be changed directly.
One bad file is one fault
A file that will not read, will not decode, names no headspace or declaration, or does not fit the
shape is a FileFault on faults(), never a throw that takes the other forty declarations with it.
Each carries the source it came from, the file, and the exception saying what is wrong with it.
Asking for a broken one by id still throws: BrokenFile, naming the file, with the real cause
as its getPrevious().
Building a headspace
$registry->headspace('Homes') builds the engine's Headspace from every declaration served in
it, with the meaning its source describes. A declaration whose file cannot be read, or whose group
is not described, is still declared, with that fault, so a declaration that uses it gets
DependsOnAFault rather than UnknownConcept. Headspace::build takes such a Fault wherever it
takes a declaration, for anyone who reads declarations some other way.
faults() builds every headspace too, so what a headspace refuses (a kind that does not fit, a
concept nobody declared) is reported with the file of the declaration it belongs to. It reads every
file; ids() does not.
Set up a project
concept-manager.json at the top of a project names the folder its own declarations are kept in
and the folders it borrows from, read-only and in order, each relative to the project:
{
"declarations": "declarations",
"borrowFrom": ["vendor/acme/catalogue/declarations"]
}
use Rmb32\ConceptManager\File\ProjectConfig;
use Rmb32\ConceptManager\Store\ProjectConfigFile;
use Rmb32\ConceptManager\Store\Registry;
ProjectConfigFile::save($root, ProjectConfig::of('declarations', ['vendor/acme/catalogue/declarations']));
$config = ProjectConfigFile::load($root); // null for a project without one
$registry = Registry::fromConfig($root, $config);
save() also makes the declarations folder. fromConfig() puts the borrowed folders first, in
order, and the project's own last, as the one written to. A folder that is blank, absolute or
climbs out through .. is refused with NotInTheProject, whether it is given or read.
Checking a change
A change to one declaration reaches everything built with it, and can break it: drop a requirement
a kind resolves, and the kind no longer builds. Rmb32\ConceptManager\Check\Checker says so before
anything is written:
use Rmb32\ConceptManager\Check\Checker;
use Rmb32\ConceptManager\Store\DeclarationId;
$checker = Checker::over($registry);
$room = DeclarationId::of('Homes', 'Room');
$checker->statuses(); // every declaration's Status, by headspace, then name
$checker->faults(); // the FileFaults no status carries
$checker->usersOf($room); // [Bedroom in Homes, Kitchen in Homes]
$checker->impactOfSaving($edited); // what saving this edited Room would change
$checker->impactOfSaving($moved, $room); // what moving it to another headspace would change
$checker->impactOfDeleting($room);
Statuses
A Status is one of three, and says which through isFaulty(), isOpen() and isComplete():
- faulty: it does not build. It carries its
fault(), the first the headspace found in it, and thepath()of its file; - open: a concept that builds with places left open.
openPlaces()is the path of each, depth first, as its outline gives them; - complete: a kind, or a concept with nothing left open.
A file that serves no declaration has no status to carry its fault: one that is not JSON, names
no declaration or names one another file serves already, and a headspaces.json that cannot be
read or describes what another source does. faults() lists those, as Registry::unserved()
does, so statuses and faults together are everything Registry::faults() reports.
Users
usersOf($id) is every declaration in the same headspace that names it, directly or through
others, by name. A declaration names what its references() gives, which takes in what its
inline declarations and its locals name. A name in another headspace is another declaration, and
one that cannot be served names nothing.
Impact
A headspace is built whole, so the impact of a change is two builds compared: each headspace the
change touches as it is, and as it would be. impactOfSaving($file, $replacing) and
impactOfDeleting($id) return a list of Change, one for each declaration in those headspaces
whose status would change, the one changed among them, by headspace and then by name. Each has
before() and after(), and:
breaks(): it builds now and would not after;mends(): the reverse;newlyOpen(): the places it would leave open that it does not now.
Two faulty statuses are alike whatever their faults, so a declaration that was faulty and still is has not changed, and neither has one whose meaning alone was reworded. Saving under another id than the one replaced, as a rename or a move to another headspace is, leaves the replaced id gone, and both headspaces are compared. A declaration only one side holds, such as a new one or the old id of a rename, is not a change itself; what it does to the rest is.
Nothing is written, and nothing is refused: a proposal put() would refuse, such as one another
source holds, is checked as if it were kept. Whether to go ahead is the caller's choice.
Projecting a tree
A kind's tree, or a concept's outline, is projected for whoever will read it.
Rmb32\ConceptManager\Projection depends on the engine, and on the files only for the rules a
folder name keeps, never the other way round. Four projectors are built in, each with a kind and a
label:
| Kind | Label | What you get | For |
|---|---|---|---|
text | Tree | one .txt file: one name per line, beneath its parent | a quick look |
markdown | Document | one .md file: a heading per instance, its meaning beneath | people |
folder | Folders | a folder per instance, each with a README.md | developers and tools |
mermaid | Diagram | one .mmd file: a Mermaid flowchart | a README, a pull request or a slide |
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Projection\ProjectionKind;
use Rmb32\ConceptManager\Projection\Projectors;
use Rmb32\ConceptManager\Projection\Writer;
$projectors = Projectors::builtIn();
$document = $projectors->get(ProjectionKind::of('markdown'))->project(Instance::of($kind));
$document->content(); // the Markdown
$document->mediaType(); // 'text/markdown'
$document->extension(); // 'md'
new Writer()->write($document, 'docs/orchard-lane.md');
project() never touches the disk. It returns a FileProjection, with its content(),
mediaType() and extension(), or a FolderProjection, whose root() is a Folder of files()
and folders(). kinds() and projectors() list what a registry holds, in order, and asking for a
kind none makes throws NoProjector, naming those there are.
What each shows
An instance is shown by its name() and meaning(). One that fills a requirement, rather than
standing at a part, is a kind, named after itself, so each projection also says which requirement
it fills.
- Tree: each name on a line of its own, indented beneath its parent with
|and+--. Each open place follows the children of the instance that leaves it open, asBoundary (open). - Document: the root is the
#title, with its meaning as the opening paragraph. Each child is a heading one level deeper, down to######, below which children are a nested list of- **name** — meaning. A child that fills a requirement is headedKitchen: Our kitchen. Open places are listed under the meaning, before the children, as- **Boundary** (open) — meaning. Names are escaped, so*best*is shown as written; meanings are written as they are, and may hold Markdown. - Folders: a folder per instance, named after it, holding a
README.mdwith its name as the title and its meaning beneath; one that fills a requirement says so (Fills *Flooring*.). An open place makes no folder. - Diagram: a
flowchart TD, with node idsn1,n2… in walk order. An edge to a child that fills a requirement is labelled with it, and an open place is a dashed node on a dotted edge. Labels are escaped so that a name can neither end its label nor be read as maths:$is written#36;. Meanings are left out.
Folder names are checked, never changed. A name holding any of / \ < > : " | ? * or a control
character, or ending with a dot or a space, cannot be a folder, and neither can two siblings whose
names differ only in case, or a child named like the README.md beside it. One kind taken twice by
a requirement for many is two siblings of one name. Every such name is refused together, in one
FolderNames, before anything is made. Each FolderNameProblem has the folders() from the root's
down to its own, its path() in the tree and its reason(), an Unsafe or a Clash, and
FolderProjector::problems() lists them without throwing. Unlike a declaration's name, a folder
may start with a dot.
Writing one
Writer::write($projection, $path) puts a projection on disk, and never overwrites or merges. A
file goes to a path where nothing is, and the folders it is in are made. A folder projection goes
to a path where nothing is, or to an empty folder, which becomes the root's folder. Anything else
there, a link included, throws TargetExists, and a file or folder the filesystem will not let it
make throws CannotWrite.
Adding a projection
Implement Projector and add it to the registry. A kind is an open value, so any package can name
one:
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Projection\FileProjection;
use Rmb32\ConceptManager\Projection\FolderProjection;
use Rmb32\ConceptManager\Projection\ProjectionKind;
use Rmb32\ConceptManager\Projection\Projector;
use Rmb32\ConceptManager\Projection\Projectors;
final readonly class SlidesProjector implements Projector
{
public function kind(): ProjectionKind
{
return ProjectionKind::of('slides');
}
public function label(): string
{
return 'Slides';
}
public function project(Instance $instance): FileProjection|FolderProjection
{
return FileProjection::of($this->kind(), '…', 'text/markdown', 'md');
}
}
$projectors = Projectors::builtIn()->with(new SlidesProjector());
with() returns a new registry, with the projector added, or put in place of the one of its kind.
Every projection is a file or a folder of files, so the writer, and any host, handles a new kind
with no more work.
Examples
A house with parts
A house whose ground floor is a part: always there, holding a hallway, a living room that is a
Room, and a kitchen still to choose. 12 Orchard Lane resolves within the ground floor, and
within the living room inside it, and invents no name to do so. This is
examples/house.php, which the tests run.
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
$headspace = Headspace::build(
'Homes',
'Houses and the rooms they are made of.',
ConceptDeclaration::new('House', 'Somewhere to live.', provides: [
Provides::part('Ground floor', 'The rooms you reach from the front door.', [
Requires::one('Kitchen', 'Where the cooking is done.', 'Kitchen'),
], [
Provides::part('Hallway', 'Where the front door opens.'),
Provides::partOf('Living room', 'Where the household sits.', 'Room'),
]),
]),
ConceptDeclaration::new('Room', 'Four walls, with the floor still to choose.', requires: [
Requires::one('Flooring', 'What the floor is finished with.', 'Flooring'),
]),
ConceptDeclaration::new('Kitchen', 'Where the cooking is done.'),
ConceptDeclaration::new('Flooring', 'A finished floor.'),
KindDeclaration::of('Galley', 'Units along both walls.', 'Kitchen'),
KindDeclaration::of('Oak boards', 'Wide oak boards, oiled.', 'Flooring'),
KindDeclaration::of('12 Orchard Lane', 'A brick house by the green.', 'House', resolves: [
Resolves::within('Ground floor', [
Resolves::to('Kitchen', 'Galley'),
Resolves::within('Living room', [Resolves::to('Flooring', 'Oak boards')]),
]),
]),
KindDeclaration::of('14 Orchard Lane', 'Next door, half planned.', 'House', resolves: [
Resolves::within('Ground floor', [Resolves::to('Kitchen', 'Galley')]),
]),
);
$house = $headspace->concept('House');
$groundFloor = $house->requirements()->get('Ground floor');
echo 'House leaves open: ', implode(', ', $house->requirements()->unmetNames()), PHP_EOL;
echo 'Ground floor is a part: ', $groundFloor->isPart() ? 'yes' : 'no', PHP_EOL;
$root = Instance::of($headspace->kind('12 Orchard Lane'));
foreach ([$root, ...$root->descendants()] as $instance) {
$steps = $instance->path()->steps();
$place = $steps === [] ? null : $steps[count($steps) - 1]->name();
$name = $instance->name();
$label = $place === null || $place === $name ? $name : $place . ': ' . $name;
echo str_repeat(' ', count($steps)), $label, PHP_EOL;
}
foreach ($headspace->faults() as $fault) {
echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}
It prints:
House leaves open: Ground floor
Ground floor is a part: yes
12 Orchard Lane
Ground floor
Hallway
Living room
Flooring: Oak boards
Kitchen: Galley
Fault in 14 Orchard Lane: Kind 14 Orchard Lane leaves requirements unmet: Ground floor/Living room/Flooring
House leaves one thing open, its ground floor, and a kind of it must meet everything inside: the
fault names the place by its path. The hallway and the living room appear under the names of
their places: each part is met by a kind named after it.
A library with locals and metadata
A library whose Exceptions folder sits under both its Api and its Internal part. It is
written once, as a local of Library, so it takes no name in the headspace. Metadata says what an
exception's slot declares and which language a class is in; a kind of Class may add to it and
never change it. This is examples/library.php, which the tests run.
use Rmb32\ConceptManager\Concept\Concept;
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Concept\UnmetRequirement;
$headspace = Headspace::build(
'Libraries',
'How a package is laid out.',
ConceptDeclaration::new('Library', 'A public surface over hidden code.', provides: [
Provides::part('Api', 'What a client may use.', provides: [
Provides::partOf('Exceptions', 'Failures a client may catch.', 'Exceptions'),
]),
Provides::part('Internal', 'What a client never sees.', provides: [
Provides::partOf('Exceptions', 'Failures kept inside.', 'Exceptions'),
]),
], locals: [
ConceptDeclaration::new('Exceptions', 'A folder of failures.', [
Requires::many('Exception', 'One failure.', 'Class', metadata: ['declares' => 'exception']),
]),
]),
ConceptDeclaration::new('Class', 'A unit of code.', metadata: ['language' => 'php']),
ConceptDeclaration::new('Package', 'Something to install.', [
Requires::one('Main', 'Where it starts.', 'Class'),
]),
KindDeclaration::of('Billing', 'Sends bills.', 'Package', [
Resolves::to('Main', KindDeclaration::of('Invoice', 'What is owed.', 'Class', metadata: [
'final' => true,
])),
]),
KindDeclaration::of('Shipping', 'Sends parcels.', 'Package', [
Resolves::to('Main', KindDeclaration::of('Parcel', 'What is sent.', 'Class', metadata: [
'language' => 'go',
])),
]),
);
$library = $headspace->concept('Library');
$held = [];
foreach (['Api', 'Internal'] as $part) {
$holding = $library->requirements()->get($part);
$exceptions = $holding instanceof UnmetRequirement
? $holding->concept()->requirements()->get('Exceptions')
: null;
$held[] = $exceptions instanceof UnmetRequirement ? $exceptions->concept() : null;
}
echo 'Concepts listed: ', implode(', ', array_map(
static fn (Concept $concept): string => $concept->name(),
$headspace->concepts(),
)), PHP_EOL;
echo 'One Exceptions under both: ', $held[0] !== null && $held[0] === $held[1] ? 'yes' : 'no', PHP_EOL;
echo 'Library leaves open: ', implode(', ', $library->requirements()->unmetPaths()), PHP_EOL;
echo 'Exception declares: ', $held[0]?->requirements()->get('Exception')->metadata()->get('declares'), PHP_EOL;
foreach (Instance::of($headspace->kind('Billing'))->descendants() as $instance) {
echo $instance->path(), ': ', $instance->name(), ' ', json_encode($instance->metadata()->toArray()), PHP_EOL;
}
foreach ($headspace->faults() as $fault) {
echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}
It prints:
Concepts listed: Library, Class, Package
One Exceptions under both: yes
Library leaves open: Api/Exceptions/Exception, Internal/Exceptions/Exception
Exception declares: exception
Main: Invoice {"language":"php","final":true}
Fault in Shipping: Metadata language is 'php', set on Class, so Parcel cannot make it 'go'
Exceptions is one concept at both places, and only Library sees it. Invoice carries the
language of Class and adds that it is final; Parcel tries to change the language, and the
fault names where it was set.
A layered application
A layered application: each context holds a domain of its own choosing, plus a fixed public API.
The declarations come top-down, in no particular order. This is
examples/layered-app.php, which the tests run.
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
$headspace = Headspace::build(
'Layered apps',
'Applications split into contexts, each with its own domain.',
ConceptDeclaration::new('App', 'A whole application.', requires: [
Requires::many('Contexts', 'The parts it is split into.', 'Context'),
]),
ConceptDeclaration::new(
'Context',
'One part of the business.',
requires: [Requires::one('Domain', 'Its own rules.', 'Domain')],
provides: [Provides::one('Public API', 'Its face to others.', 'Public API')],
),
ConceptDeclaration::new('Domain', 'The rules of one context.', requires: [
Requires::many('Aggregates', 'What the rules govern.', 'Aggregate', atMost: 7),
]),
ConceptDeclaration::new('Aggregate', 'A cluster changed as one.'),
KindDeclaration::standalone('Public API', 'What a context offers others.'),
KindDeclaration::of('Shop', 'An online shop.', 'App', resolves: [
Resolves::to('Contexts', ['Ordering', 'Billing']),
]),
KindDeclaration::of('Ordering', 'Taking orders.', 'Context', resolves: [
Resolves::to('Domain', 'Ordering domain'),
]),
KindDeclaration::of('Billing', 'Sending bills.', 'Context', resolves: [
Resolves::to('Domain', 'Empty domain'),
]),
KindDeclaration::of('Ordering domain', 'The rules of ordering.', 'Domain', resolves: [
Resolves::to('Aggregates', ['Order', 'Basket']),
]),
KindDeclaration::of('Empty domain', 'Nothing in it yet.', 'Domain', resolves: [
Resolves::to('Aggregates', []),
]),
KindDeclaration::of('Order', 'What a customer asks for.', 'Aggregate'),
KindDeclaration::of('Basket', 'What a customer means to buy.', 'Aggregate'),
KindDeclaration::of('Sprawling domain', 'Everything in one place.', 'Domain', resolves: [
Resolves::to('Aggregates', array_fill(0, 8, 'Order')),
]),
KindDeclaration::of('Undecided domain', 'Not thought about yet.', 'Domain'),
);
$shop = $headspace->kind('Shop');
$context = $headspace->concept('Context');
echo 'Shop leaves nothing open: ', $shop->requirements()->areMet() ? 'yes' : 'no', PHP_EOL;
echo 'Context leaves open: ', implode(', ', $context->requirements()->unmetNames()), PHP_EOL;
echo 'Kinds of Context: ', implode(', ', $headspace->kindsOf('Context')), PHP_EOL;
$root = Instance::of($shop);
foreach ([$root, ...$root->descendants()] as $instance) {
$steps = $instance->path()->steps();
$place = $steps === [] ? '' : $steps[count($steps) - 1] . ': ';
echo str_repeat(' ', count($steps)), $place, $instance->name(), PHP_EOL;
}
foreach ($headspace->faults() as $fault) {
echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}
It prints:
Shop leaves nothing open: yes
Context leaves open: Domain
Kinds of Context: Ordering, Billing
Shop
Contexts[0]: Ordering
Domain: Ordering domain
Aggregates[0]: Order
Aggregates[1]: Basket
Public API: Public API
Contexts[1]: Billing
Domain: Empty domain
Public API: Public API
Fault in Sprawling domain: Requirement Aggregates takes at most 7 kinds, not 8
Fault in Undecided domain: Kind Undecided domain leaves requirements unmet: Aggregates
An empty domain is a decision; a domain that says nothing about its aggregates is refused, and so
is one with more than the seven a domain takes. The rest of the headspace is built anyway. Public API appears under both contexts: one kind, two
instances.
A garden, before anything is chosen
Barncept's garden: a shed and a path, with its boundary and planting still to choose. Its outline
lists what is open at each place, metadata is supplied for the shed and the boundary, and the
headspace says where to start. This is examples/garden.php, which the
tests run.
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Concept\Instance\OpenPlace;
use Rmb32\ConceptManager\Concept\Instance\Path;
use Rmb32\ConceptManager\Concept\Instance\Step;
use Rmb32\ConceptManager\Concept\Instance\SuppliedMetadata;
use Rmb32\ConceptManager\Concept\Kind;
use Rmb32\ConceptManager\Concept\Metadata;
$headspace = Headspace::build(
'Homes',
'Houses and the ground around them.',
ConceptDeclaration::new('Garden', 'Ground outside the house, with a shed in one corner.', requires: [
Requires::one('Boundary', 'What marks where the garden ends.', 'Boundary', ['visible' => true]),
Requires::many('Planting', 'What grows in the beds.', 'Plant'),
], provides: [
Provides::part('Shed', 'Where the tools are kept.', [
Requires::many('Tools', 'What hangs on the wall.', 'Tool'),
]),
Provides::part('Path', 'Runs from the back door to the shed.'),
]),
ConceptDeclaration::new('Boundary', 'Where one plot meets the next.'),
ConceptDeclaration::new('Plant', 'Something that grows.'),
ConceptDeclaration::new('Tool', 'Something to work the ground with.'),
KindDeclaration::of('Hedge', 'Privet, kept low.', 'Boundary'),
KindDeclaration::of('Roses', 'Climbing, by the door.', 'Plant'),
KindDeclaration::of('Our garden', 'Behind 12 Orchard Lane.', 'Garden', resolves: [
Resolves::to('Boundary', 'Hedge'),
Resolves::to('Planting', ['Roses']),
Resolves::within('Shed', [Resolves::to('Tools', [])]),
]),
);
$outline = Instance::of($headspace->concept('Garden'), SuppliedMetadata::none()
->at(Path::of(Step::of('Shed')), Metadata::of(['wrapper' => true]))
->at(Path::of(Step::of('Boundary')), Metadata::of(['height' => 2])));
$said = static fn (Metadata $metadata): string => $metadata->keys() === []
? ''
: ' ' . json_encode($metadata->toArray());
foreach ([$outline, ...$outline->descendants()] as $instance) {
$depth = count($instance->path()->steps());
echo str_repeat(' ', $depth), $instance->name(), $said($instance->metadata()), PHP_EOL;
foreach ($instance->open() as $place) {
$name = $place->requirement()->name();
echo str_repeat(' ', $depth + 1), $name, ' (open)', $said($place->metadata()), PHP_EOL;
}
}
echo 'Open: ', implode(', ', array_map(
static fn (OpenPlace $place): string => (string) $place->path(),
$outline->openPlaces(),
)), PHP_EOL;
echo 'Start from: ', implode(', ', array_map(
static fn (Kind $kind): string => $kind->name(),
$headspace->entryPoints(),
)), PHP_EOL;
It prints:
Garden
Boundary (open) {"visible":true,"height":2}
Planting (open)
Shed {"wrapper":true}
Tools (open)
Path
Open: Boundary, Planting, Shed/Tools
Start from: Our garden
The shed is a part, so it is laid out though its tools are open; the boundary is a requirement, so
nothing is laid out beneath it. Our garden is the one kind nothing names: the hedge and the roses
are named by it.
A source tree
A folder holds folders, so Folder asks for itself, and the requirement it asks with holds the
very concept the headspace returns. Each kind names the folders inside it and ends with an empty
list. This is examples/source-tree.php, which the tests run.
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Concept\UnmetRequirement;
$headspace = Headspace::build(
'Source trees',
'The folders a project keeps its code in.',
ConceptDeclaration::new('Folder', 'A place that holds files and other folders.', requires: [
Requires::many('Folders', 'The folders inside it.', 'Folder'),
]),
KindDeclaration::of('src', 'Where the code lives.', 'Folder', resolves: [
Resolves::to('Folders', ['Domain', 'Infrastructure']),
]),
KindDeclaration::of('Domain', 'The rules.', 'Folder', resolves: [
Resolves::to('Folders', [
KindDeclaration::of('Model', 'What the rules are about.', 'Folder', resolves: [
Resolves::to('Folders', []),
]),
]),
]),
KindDeclaration::of('Infrastructure', 'How the rules reach the world.', 'Folder', resolves: [
Resolves::to('Folders', []),
]),
KindDeclaration::of('Mirror', 'A folder inside itself.', 'Folder', resolves: [
Resolves::to('Folders', ['Mirror']),
]),
);
$folder = $headspace->concept('Folder');
$inside = $folder->requirements()->get('Folders');
$asksForItself = $inside instanceof UnmetRequirement && $inside->concept() === $folder;
echo 'Folder asks for itself: ', $asksForItself ? 'yes' : 'no', PHP_EOL;
echo 'Kinds of Folder: ', implode(', ', $headspace->kindsOf('Folder')), PHP_EOL;
$root = Instance::of($headspace->kind('src'));
foreach ([$root, ...$root->descendants()] as $instance) {
$depth = count($instance->path()->steps());
echo str_repeat(' ', $depth), $instance->name(), '/', PHP_EOL;
}
foreach ($headspace->faults() as $fault) {
echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}
It prints:
Folder asks for itself: yes
Kinds of Folder: src, Domain, Infrastructure
src/
Domain/
Model/
Infrastructure/
Fault in Mirror: Declared in a loop: Mirror
Mirror fills its own Folders with itself, so its tree would never end: asking for itself is
sound, a kind holding itself is not.
A kitchen in files
Two files, a concept and a kind of it, read and built into one headspace; then a file that does
not fit, with every fault in it at once. The kind's units and range are kinds that fill, written in
place. This is examples/files.php, which the tests run.
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\File\Exception\NotWellFormed;
use Rmb32\ConceptManager\File\Reader;
$reader = new Reader();
$kitchen = $reader->fromJson(<<<'JSON'
{
"headspace": "Homes",
"group": "Indoors",
"concept": {
"name": "Kitchen",
"meaning": "Where meals are made.",
"requires": [
{ "name": "Cooker", "meaning": "What heats the food.", "one": "Cooker" }
],
"provides": [
{ "name": "Worktop", "meaning": "Where food is prepared.", "part": {
"requires": [
{ "name": "Units", "meaning": "What sits beneath it.", "many": "Unit", "atLeast": 1 }
]
} }
],
"locals": [
{ "concept": { "name": "Cooker", "meaning": "Something that cooks." } },
{ "concept": { "name": "Unit", "meaning": "A cupboard or drawers." } }
]
}
}
JSON);
$ours = $reader->fromJson(<<<'JSON'
{
"headspace": "Homes",
"group": "Indoors",
"kind": {
"name": "Our kitchen",
"meaning": "Painted units and a range.",
"of": "Kitchen",
"resolves": [
{ "requirement": "Cooker", "kinds": [
{ "name": "Range cooker", "meaning": "Cast iron, two ovens.", "fills": true }
] },
{ "within": "Worktop", "resolves": [
{ "requirement": "Units", "kinds": [
{ "name": "Painted units", "meaning": "Shaker doors, painted sage.", "fills": true }
] }
] }
],
"metadata": { "built": 1998 }
}
}
JSON);
$headspace = Headspace::build(
$kitchen->headspace(),
'Houses and the rooms in them.',
$kitchen->declaration(),
$ours->declaration(),
);
$tree = Instance::of($headspace->kind('Our kitchen'));
foreach ([$tree, ...$tree->descendants()] as $instance) {
echo str_repeat(' ', count($instance->path()->steps())), $instance->name(), PHP_EOL;
}
try {
$reader->fromJson(<<<'JSON'
{
"headspace": "Homes",
"concept": {
"name": "Larder",
"meaning": "Where food is kept cool.",
"requires": [
{ "name": "Shelves", "meaning": "What holds the jars.", "one": "Shelf", "many": "Shelf" },
{ "name": "Door", "meaning": "The way in.", "one": "Door", "colour": "green" }
],
"provides": [
{ "name": "Light", "meaning": "", "part": {} }
]
}
}
JSON);
} catch (NotWellFormed $exception) {
foreach ($exception->faults() as $fault) {
echo $fault->path(), ': ', $fault->message(), ' (', $fault->code(), ')', PHP_EOL;
}
}
It prints:
Our kitchen
Worktop
Painted units
Range cooker
concept.requires.0: Exactly one of `one` and `many` is written (not_one_of)
concept.requires.1.colour: Unexpected key `colour`. (unexpected_key)
concept.provides.0.meaning: Value '' is not a valid non-empty string. (invalid_non_empty_string)
The headspace name comes from the file; its meaning does not, since a file holds a declaration,
not a headspace. The larder's Light has an empty meaning, which typed-json refuses as a string
that may not be empty; one of spaces only would be blank_meaning, at concept.provides.0.
A shipped catalogue and your own folder
A read-only source that describes Homes and holds a Kitchen, beside your own folder, which
describes Pets and is written to through the registry. A copy of the shipped kitchen in your
folder does not shadow it, and a shipped file that is not JSON is one fault, not the end of the
source. This is examples/storage.php, which the tests run.
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\File\DeclarationFile;
use Rmb32\ConceptManager\File\DescribedHeadspace;
use Rmb32\ConceptManager\File\Writer;
use Rmb32\ConceptManager\Store\FilesystemStore;
use Rmb32\ConceptManager\Store\Registry;
$root = sys_get_temp_dir() . '/concept-manager-storage-' . getmypid();
$shipped = $root . '/shipped';
$mine = $root . '/mine';
mkdir($shipped, recursive: true);
mkdir($mine, recursive: true);
$kitchen = new Writer()->toJson(DeclarationFile::of(
'Homes',
ConceptDeclaration::new('Kitchen', 'Where meals are made.'),
));
// The shipped catalogue describes the headspace its declarations are in.
file_put_contents($shipped . '/headspaces.json', <<<'JSON'
{
"headspaces": [
{ "name": "Homes", "meaning": "Houses and the rooms in them." }
]
}
JSON);
file_put_contents($shipped . '/kitchen.json', $kitchen);
file_put_contents($shipped . '/larder.json', '{"headspace": ');
// A copy of a shipped declaration does not shadow it: it is a fault.
file_put_contents($mine . '/kitchen.json', $kitchen);
$registry = Registry::over(FilesystemStore::readOnly($shipped), FilesystemStore::writable($mine));
// Your own folder describes a headspace of its own, and writes into both.
$registry->describeHeadspace(DescribedHeadspace::of('Pets', 'Animals kept at home.'));
$registry->put(DeclarationFile::of('Pets', ConceptDeclaration::new('Cat', 'A small feline.')));
$registry->put(DeclarationFile::of('Homes', KindDeclaration::of('Galley', 'Long and thin.', 'Kitchen')));
foreach ($registry->headspaces() as $headspace) {
echo $headspace->name(), ': ', $headspace->meaning(), PHP_EOL;
foreach ($registry->ids() as $id) {
if ($id->headspace() === $headspace->name()) {
printf(
" %-8s from %-8s %s\n",
$id->name(),
basename($registry->origin($id)),
$registry->isWritable($id) ? '(yours)' : '(read-only)',
);
}
}
}
$homes = $registry->headspace('Homes');
echo 'Kinds of Kitchen: ', implode(', ', $homes->kindsOf('Kitchen')), PHP_EOL;
foreach ($registry->faults() as $fault) {
echo 'fault: ', str_replace($root . '/', '', (string) $fault), PHP_EOL;
}
$remove = static function (string $path) use (&$remove): void {
foreach (glob($path . '/*') ?: [] as $entry) {
is_dir($entry) ? $remove($entry) : unlink($entry);
}
rmdir($path);
};
$remove($root);
It prints:
Homes: Houses and the rooms in them.
Galley from mine (yours)
Kitchen from shipped (read-only)
Pets: Animals kept at home.
Cat from mine (yours)
Kinds of Kitchen: Galley
fault: shipped/larder.json is not JSON: Syntax error
fault: mine/kitchen.json: Kitchen in Homes is already held by shipped
Galley, a kind of the shipped Kitchen, is built from your folder into the same headspace.
Checking a change before it is kept
A Room asks for its Flooring, a kind of room resolves it, and a concept extending room leaves
it open. Saving Room without it breaks the kind; saving it with Lighting as well breaks the
kind again and opens a place in everything else. This is
examples/checking.php, which the tests run.
use Rmb32\ConceptManager\Check\Checker;
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\File\DeclarationFile;
use Rmb32\ConceptManager\File\DescribedHeadspace;
use Rmb32\ConceptManager\Store\DeclarationId;
use Rmb32\ConceptManager\Store\FilesystemStore;
use Rmb32\ConceptManager\Store\Registry;
$root = sys_get_temp_dir() . '/concept-manager-checking-' . getmypid();
$registry = Registry::over(FilesystemStore::writable($root));
$registry->describeHeadspace(DescribedHeadspace::of('Homes', 'Houses and the rooms in them.'));
$flooring = Requires::one('Flooring', 'What it stands on.', 'Finish');
$lighting = Requires::one('Lighting', 'What lights it.', 'Finish');
$room = static fn (Requires ...$requires): DeclarationFile => DeclarationFile::of(
'Homes',
ConceptDeclaration::new('Room', 'A space in a house.', requires: array_values($requires)),
);
$registry->put($room($flooring));
foreach (
[
ConceptDeclaration::new('Finish', 'What a surface is covered with.'),
KindDeclaration::of('Tiles', 'Laid in a grid.', 'Finish'),
KindDeclaration::of('Kitchen', 'Where meals are made.', 'Room', resolves: [Resolves::to('Flooring', 'Tiles')]),
ConceptDeclaration::extending('Bedroom', 'Where people sleep.', 'Room'),
] as $declaration
) {
$registry->put(DeclarationFile::of('Homes', $declaration));
}
$checker = Checker::over($registry);
foreach ($checker->statuses() as $status) {
echo $status, PHP_EOL;
}
$users = $checker->usersOf(DeclarationId::of('Homes', 'Room'));
echo 'Users of Room: ', implode(', ', array_map(static fn (DeclarationId $id): string => $id->name(), $users)), PHP_EOL;
// Nothing is written: each impact is the headspace built twice and compared.
$proposals = ['Room without Flooring' => $room(), 'Room with Lighting too' => $room($flooring, $lighting)];
foreach ($proposals as $saving => $file) {
echo PHP_EOL, 'Saving ', $saving, ':', PHP_EOL;
foreach ($checker->impactOfSaving($file) as $change) {
echo ' ', $change->after(), match (true) {
$change->breaks() => ' (breaks)',
$change->mends() => ' (mends)',
$change->newlyOpen() !== [] => ' (newly open at ' . implode(', ', $change->newlyOpen()) . ')',
default => '',
}, PHP_EOL;
}
}
$remove = static function (string $path) use (&$remove): void {
foreach (glob($path . '/*') ?: [] as $entry) {
is_dir($entry) ? $remove($entry) : unlink($entry);
}
rmdir($path);
};
$remove($root);
It prints:
Bedroom in Homes is open at Flooring
Finish in Homes is complete
Kitchen in Homes is complete
Room in Homes is open at Flooring
Tiles in Homes is complete
Users of Room: Bedroom, Kitchen
Saving Room without Flooring:
Bedroom in Homes is complete
Kitchen in Homes is faulty: No requirement is named Flooring (breaks)
Room in Homes is complete
Saving Room with Lighting too:
Bedroom in Homes is open at Flooring, Lighting (newly open at Lighting)
Kitchen in Homes is faulty: Kind Kitchen leaves requirements unmet: Lighting (breaks)
Room in Homes is open at Flooring, Lighting (newly open at Lighting)
Bedroom and Room change without breaking: one fewer place, or one more, is left open.
A dinner party, projected
Friday with the Hendersons is a dinner party with its starter and main chosen: projected as a
document and a diagram, then written as folders and listed from the disk. A garden with nothing
chosen yet is projected as its outline, open places and all. This is
examples/projections.php, Barncept's own in Concept Manager's words,
which the tests run.
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Projection\FileProjection;
use Rmb32\ConceptManager\Projection\FolderProjection;
use Rmb32\ConceptManager\Projection\ProjectionKind;
use Rmb32\ConceptManager\Projection\Projectors;
use Rmb32\ConceptManager\Projection\Writer;
$dinners = Headspace::build(
'Dinner parties',
'Evening meals for guests.',
ConceptDeclaration::new('Course', 'One dish of the meal.'),
ConceptDeclaration::new(
'Dinner party',
'An evening meal for guests.',
requires: [
Requires::one('Starter', 'Served first.', 'Course'),
Requires::one('Main', 'The centre of the meal.', 'Course'),
],
provides: [Provides::part('Shopping list', 'Bought the day before.')],
),
KindDeclaration::of('Tomato soup', 'Made the day before.', 'Course'),
KindDeclaration::of('Mushroom risotto', 'Everyone eats it.', 'Course'),
KindDeclaration::of('Friday with the Hendersons', 'Six guests.', 'Dinner party', resolves: [
Resolves::to('Starter', 'Tomato soup'),
Resolves::to('Main', 'Mushroom risotto'),
]),
);
$homes = Headspace::build(
'Homes',
'Houses and the ground around them.',
ConceptDeclaration::new('Boundary', 'What marks where ground ends.'),
ConceptDeclaration::new('Planting', 'What grows.'),
ConceptDeclaration::new(
'Garden',
'Ground outside the house.',
requires: [
Requires::one('Boundary', 'Where the garden ends.', 'Boundary'),
Requires::one('Planting', 'What grows in the beds.', 'Planting'),
],
provides: [Provides::part('Shed', 'Where the tools are kept.')],
),
);
$friday = Instance::of($dinners->kind('Friday with the Hendersons'));
$projectors = Projectors::builtIn();
foreach (['markdown', 'mermaid'] as $kind) {
$file = $projectors->get(ProjectionKind::of($kind))->project($friday);
if ($file instanceof FileProjection) {
echo '--- ', $file->mediaType(), PHP_EOL, $file->content();
}
}
$folders = $projectors->get(ProjectionKind::of('folder'))->project($friday);
$root = sys_get_temp_dir() . '/concept-manager-projections-' . getmypid();
if ($folders instanceof FolderProjection) {
new Writer()->write($folders, $root . '/' . $folders->root()->name());
echo '--- folders', PHP_EOL;
$entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
$paths = [];
foreach ($entries as $entry) {
/** @var SplFileInfo $entry */
$paths[] = substr($entry->getPathname(), strlen($root) + 1);
}
sort($paths);
echo implode(PHP_EOL, $paths), PHP_EOL;
}
// An outline shows what is still open.
$garden = Instance::of($homes->concept('Garden'));
foreach (['text', 'markdown'] as $kind) {
$file = $projectors->get(ProjectionKind::of($kind))->project($garden);
if ($file instanceof FileProjection) {
echo '--- outline, ', $file->mediaType(), PHP_EOL, $file->content();
}
}
$remove = static function (string $path) use (&$remove): void {
foreach (glob($path . '/*') ?: [] as $entry) {
is_dir($entry) ? $remove($entry) : unlink($entry);
}
rmdir($path);
};
$remove($root);
It prints:
--- text/markdown
# Friday with the Hendersons
Six guests.
## Shopping list
Bought the day before.
## Starter: Tomato soup
Made the day before.
## Main: Mushroom risotto
Everyone eats it.
--- text/vnd.mermaid
flowchart TD
n1["Friday with the Hendersons"]
n2["Shopping list"]
n1 --> n2
n3["Tomato soup"]
n1 -->|"Starter"| n3
n4["Mushroom risotto"]
n1 -->|"Main"| n4
--- folders
Friday with the Hendersons/Mushroom risotto/README.md
Friday with the Hendersons/README.md
Friday with the Hendersons/Shopping list/README.md
Friday with the Hendersons/Tomato soup/README.md
--- outline, text/plain
Garden
|
+--Shed
+--Boundary (open)
+--Planting (open)
--- outline, text/markdown
# Garden
Ground outside the house.
- **Boundary** (open) — Where the garden ends.
- **Planting** (open) — What grows in the beds.
## Shed
Where the tools are kept.
Not yet
More than one parent, renaming, adding a requirement inside an inherited part, and conditions on a tree, such as "a kitchen somewhere inside".