@bakers-build/core 0.1.2-next.133 → 0.1.2-next.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,4 +25,3 @@ export { DEFAULT_SLUG_MAX_LENGTH, slugify } from './core/slug.js';
25
25
  export { AddonUnresolvedError, discoverAddons, loadAddonPreset, type ResolvedConductorSlots, resolveConductorSlots, } from './plugins/discover-addons.js';
26
26
  export { type AddonEntry, type AddonScope, type BbPreset, type BbSurface, type CommandDescriptor, type DiscoveredContributions, emptyContributions, type OpaqueAccumulator, PRESET_FUNCTION_KEYS, SURFACE_KEYS, } from './plugins/preset.js';
27
27
  export { type AddonScopeFinding, type AddonScopeInput, discoverScoped, type EffectiveAddon, readConfigAddons, readEffectiveAddon, scopeAddons, } from './plugins/scoped-addons.js';
28
- export { deriveProjectTriples, type EmptyLoaderShape, type ExpandedLoad, expandLoad, type IndentedLoadSite, type InputDecl, measureEmptyLoader, parseStepValues, readSkillWhen, type SkillContract, type SkillWhen, StepContractError, type StepContractErrorCode, type StepValues, type Triple, validateLoad, type WhenValue, WORKFLOW_CLAIMABLE_STATES, WORKFLOW_STEPS, type WorkflowStep, } from './workflow/step-contract.js';
package/dist/src/index.js CHANGED
@@ -41,4 +41,3 @@ export { discoverScoped, readConfigAddons, readEffectiveAddon, scopeAddons, } fr
41
41
  // BB-194 AC-2: the agent-host contract (`AgentLaunchHost` / `HostedAgent` /
42
42
  // `supportsAgentHost`) is likewise NOT here — it is the TUI renderer's own plugin
43
43
  // seam (`@bakers-build/addon-bakers-ui`), and herdr types its impl locally.
44
- export { deriveProjectTriples, expandLoad, measureEmptyLoader, parseStepValues, readSkillWhen, StepContractError, validateLoad, WORKFLOW_CLAIMABLE_STATES, WORKFLOW_STEPS, } from './workflow/step-contract.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakers-build/core",
3
- "version": "0.1.2-next.133",
3
+ "version": "0.1.2-next.163",
4
4
  "description": "bakers.build surface-agnostic kernel: host contract, split-config helpers, addon preset/discovery machinery, shared primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,233 +0,0 @@
1
- /** The workflow states an agent works — the legal `step` values. */
2
- export declare const WORKFLOW_STEPS: readonly ["qualification", "plan", "working", "spec", "diagnose", "coding", "review", "pre_deployment", "post_deployment", "verifying", "summary"];
3
- export type WorkflowStep = (typeof WORKFLOW_STEPS)[number];
4
- /**
5
- * The machine-readable classification of a contract defect, for consumers that
6
- * must branch on *which* defect it is rather than on what it says.
7
- *
8
- * BB-436: `bakers validate` used to recover this from a regex over the message
9
- * text (`/^loaded skill \`([^\`]+)\` not found\.$/`). That leaked twice — the
10
- * sibling defect raised by the same `walk()` (`bundle \`x\` loads unknown skill
11
- * \`y\`.`) is the same class of problem and matched nothing, so it got no remedy
12
- * printed; and rewording any message would have broken the consumer across the
13
- * package boundary without a single failing test to say so.
14
- *
15
- * Deliberately narrow: exactly the two codes a caller branches on **today**. Most
16
- * throw sites in this module carry no code, and that is correct — a code is added
17
- * when a consumer needs to distinguish that defect, never speculatively, because
18
- * an unused code is a contract nobody validates.
19
- */
20
- export type StepContractErrorCode = 'unresolved_skill' | 'empty_loader';
21
- /**
22
- * Typed defect raised on the first contract violation.
23
- *
24
- * The single-argument form `new StepContractError(message)` is the norm and stays
25
- * the norm; `details` is opt-in for the few defects a consumer classifies.
26
- */
27
- export declare class StepContractError extends Error {
28
- /** The defect's classification, when it is one a consumer branches on. */
29
- readonly code?: StepContractErrorCode;
30
- /**
31
- * For `unresolved_skill`: the skill name that could not be resolved — the root
32
- * bullet's, or the bundle member's. Named `skill` rather than `name` because
33
- * `Error.name` is already taken (and set to `StepContractError` below).
34
- */
35
- readonly skill?: string;
36
- constructor(message: string, details?: {
37
- code: StepContractErrorCode;
38
- skill?: string;
39
- });
40
- }
41
- /**
42
- * A normalized `when` clause value: a concrete list of allowed values, the
43
- * wildcard `'*'` (matches anything), or `undefined` (key omitted — also any).
44
- */
45
- export type WhenValue = string[] | '*' | undefined;
46
- /** A skill's `when` triple. Any of the three keys may be omitted (= any). */
47
- export interface SkillWhen {
48
- work_type: WhenValue;
49
- workflow: WhenValue;
50
- step: WhenValue;
51
- }
52
- /** One declared input: a value key with a short description and an optional default. */
53
- export interface InputDecl {
54
- /** The value key the skill reads (e.g. `test.command`). */
55
- key: string;
56
- /** A short human description of what the value is. */
57
- description?: string;
58
- /** The default used when `WORKFLOW.md` supplies no override for this key. */
59
- default?: string;
60
- }
61
- /** The parsed contract of a single skill's frontmatter. */
62
- export interface SkillContract {
63
- /** Skill name from frontmatter. */
64
- name: string;
65
- /**
66
- * The `when` triple, or `undefined` when the skill declares no `when` — a
67
- * helper/capability skill that owns no step and routes nothing.
68
- */
69
- when?: SkillWhen;
70
- /** The inputs the skill declares (each: key + description + optional default). */
71
- inputs: InputDecl[];
72
- /**
73
- * A **bundle** skill carries its own `## Loaded skills` section in its markdown
74
- * **body** — the same list a `WORKFLOW.md` carries. `loads` holds those member
75
- * names in declared order. Nothing in the frontmatter marks a bundle; the
76
- * section is the marker. Present only on bundles.
77
- */
78
- loads?: string[];
79
- /**
80
- * A bundle's member values: the inline `key: value` overrides written under a
81
- * member's bullet in the bundle body, keyed by member skill name. Present only
82
- * on bundles.
83
- */
84
- values?: StepValues;
85
- }
86
- /**
87
- * A concrete `(work_type, workflow, step)` the project can produce. `work_type`
88
- * is `null` for a non-work-typed step (qualification, plan, deployment,
89
- * verification), a `work:*` label (without the prefix) otherwise.
90
- */
91
- export interface Triple {
92
- work_type: string | null;
93
- workflow: string;
94
- step: WorkflowStep;
95
- }
96
- /** Per-skill values, keyed by skill `name` (the namespace prefix stripped). */
97
- export type StepValues = Record<string, Record<string, string>>;
98
- /** One `- @…` line the indent-0 anchor read as value rather than as an address. */
99
- export interface IndentedLoadSite {
100
- /** 1-based line number, so a report can point the author straight at it. */
101
- line: number;
102
- /** Width of the whitespace before the marker — a TAB counts as one. */
103
- indent: number;
104
- /** The line without its indent: the address the author meant to write. */
105
- text: string;
106
- }
107
- /** What a `## Loaded skills` section that produced no load site actually holds. */
108
- export interface EmptyLoaderShape {
109
- /** The indented address-shaped lines, in document order. */
110
- indented: IndentedLoadSite[];
111
- /** Non-blank lines in the section body; `0` means the section is empty. */
112
- bodyLines: number;
113
- }
114
- /**
115
- * Measure a `## Loaded skills` section that yielded no load site, so a consumer
116
- * can say WHICH of the shapes behind `empty_loader` it is looking at.
117
- *
118
- * BB-436: that code covers three documents — a list that is wholly indented, a
119
- * section whose lines are not address-shaped at all (a `*` marker, a `-@` with no
120
- * space), and an empty section. Only the first is the "your list is indented" the
121
- * single message can describe; the other two get a remedy pointing at whitespace
122
- * that is already fine, which is the same misdirection the anchoring fixed.
123
- *
124
- * It lives HERE, not in the CLI that renders it: where the section starts and ends
125
- * and what counts as an address are rules of this module, and a second copy of
126
- * them in the host is exactly the drift this ticket is about. The host measures
127
- * (in `runValidate`) and renders (in `formatReport`); the rule stays in core.
128
- *
129
- * Answers for any document — a section it cannot find simply holds nothing — so a
130
- * caller need not pre-check. It classifies, never throws: the defect is already
131
- * raised by `expandLoad` before this is reached.
132
- */
133
- export declare function measureEmptyLoader(workflowMd: string): EmptyLoaderShape;
134
- /**
135
- * Read the per-skill value **overrides** from a `WORKFLOW.md`'s `## Loaded skills`
136
- * section. A project overrides a skill default **inline under that skill's bullet**
137
- * (indented `key: value` YAML, block scalars allowed), not in one global block.
138
- * Returns a map keyed by skill `name` (any `@<namespace>/` prefix stripped); a skill
139
- * with no overrides maps to `{}` (it runs on its declared defaults).
140
- */
141
- export declare function parseStepValues(workflowMd: string): StepValues;
142
- /**
143
- * Parse a skill's frontmatter into its `SkillContract` — the `when` triple (when
144
- * present) and the declared `inputs`. Malformed frontmatter raises `StepContractError`.
145
- */
146
- export declare function readSkillWhen(skillMd: string): SkillContract;
147
- /**
148
- * The **claimable states** of each bakers.build workflow — the only `step` values a ticket
149
- * in that workflow can ever present to a step owner.
150
- *
151
- * Source of truth: `web/modules/custom/bb_core/bb_core.workflows.yml`. It is
152
- * transcribed here rather than read, because this module is a pure library with no
153
- * filesystem and no Drupal access. A workflow added there must be added here too.
154
- */
155
- export declare const WORKFLOW_CLAIMABLE_STATES: Record<string, readonly WorkflowStep[]>;
156
- /**
157
- * Derive the `(work_type, workflow, step)` set a project can produce, from the
158
- * skills it loads plus the fixed workflow/claimable-state table above — the input
159
- * `validateLoad` needs, which until BB-436 only the contract test could build
160
- * (from a hardcoded list) and therefore no shipped command could.
161
- *
162
- * A **work type** exists for the project because some loaded **owner** declares it;
163
- * its participating steps are the union of `when.step` across every owner declaring
164
- * it. Those steps are then crossed against **every** workflow — not merely the
165
- * ones the declaring skill happens to list — because that is what makes a coverage
166
- * gap real rather than tautological: a project that can route `work:code` at
167
- * `working` must be able to route it at `working` in every workflow that claims there.
168
- *
169
- * A step no work type participates in carries a `null` work type (qualification,
170
- * the deployment and verification steps). An owner whose `when.work_type` is `*` or
171
- * omitted is a non-work-typed owner: it contributes to the `null` side, never to a
172
- * named work type. Helpers (no `when`) contribute nothing — they own no step.
173
- *
174
- * **Known blind spot: the work-type universe is derived from the load, not from the
175
- * project.** Because a work type exists here only if some loaded owner declares it,
176
- * deleting the last owner of a work type does not open a coverage gap — it shrinks
177
- * the triple set, and the validation stays green. Removing `- @bakers-build/docs-authoring`
178
- * from this repository's own `WORKFLOW.md` drops one owner and its triples, all
179
- * still covered, although every `work:docs` ticket would then reach `working`
180
- * with no owner at all. The set of work types a project's tickets can actually
181
- * carry lives in the ticket store's `work:*` vocabulary, which this function cannot
182
- * see; closing the gap means feeding that vocabulary in, which is its own change.
183
- * What is validated here is therefore: *given what the project loads, is the load
184
- * internally complete and unambiguous* — not *does the project load everything its
185
- * tickets need*.
186
- */
187
- export declare function deriveProjectTriples(skills: SkillContract[]): Triple[];
188
- /** What a load expands to: the flat leaf skills plus the values that reached them. */
189
- export interface ExpandedLoad {
190
- /** The leaf skills (step-owners + helpers), deduplicated, in first-seen order. */
191
- skills: SkillContract[];
192
- /** The effective per-skill values, keyed by skill name. */
193
- values: StepValues;
194
- }
195
- /**
196
- * Read a `WORKFLOW.md` and expand what it loads into the flat, deduplicated list of
197
- * **leaf** skills (step-owners + helpers) the project effectively runs, together with
198
- * the values that reach each leaf. One call is the whole load step: parse, expand,
199
- * resolve — hand the result straight to `validateLoad`.
200
- *
201
- * A **bundle** — a skill whose markdown body carries its own `## Loaded skills`
202
- * section, so its contract carries `loads` — is recursed into, in its declared
203
- * member order; a step-owner or helper is emitted as itself. Leaves are
204
- * deduplicated **by name, first occurrence wins**, so the result is deterministic
205
- * and idempotent — listing the same bundle twice, or a bundle plus one of its
206
- * members as an explicit bullet, yields each leaf exactly once in first-seen order.
207
- *
208
- * **Value precedence**, narrowest wins: the project's own inline override in
209
- * `WORKFLOW.md` beats a bundle's inline value for that member, which beats the
210
- * skill's declared `default` (resolved later, in `validateLoad`). Between nested
211
- * bundles the outermost wins, matching first-occurrence dedup.
212
- *
213
- * A root or member name absent from `byName`, or a cycle in the bundle graph,
214
- * raises a typed `StepContractError`. Bundles may nest (cycle-safe); bakers ships one
215
- * flat level. This never dispatches or routes.
216
- */
217
- export declare function expandLoad(workflowMd: string, byName: Map<string, SkillContract>): ExpandedLoad;
218
- /**
219
- * Validate a loaded set of skills against the project's triple set and per-skill values.
220
- *
221
- * For every `(work_type, workflow, step)` the project can produce, **exactly one**
222
- * loaded skill's `when` must match — a triple with no match (gap) or two matches
223
- * (collision) is a configuration error. Every `input` a matched skill declares must
224
- * resolve to a value: the `values` map (as resolved by `expandLoad`) or the input's
225
- * own declared `default`.
226
- *
227
- * Finally (BB-436) every override the load resolved must reach a declared input:
228
- * an override key no loaded skill declares is a defect, because it resolves to
229
- * nothing at all and the skill silently runs on its default instead.
230
- *
231
- * Reports the first defect as a typed `StepContractError`. Never dispatches or routes.
232
- */
233
- export declare function validateLoad(skills: SkillContract[], values: StepValues, projectTriples: Triple[]): void;
@@ -1,678 +0,0 @@
1
- /**
2
- * BB-204 — workflow step contract (pure library).
3
- *
4
- * "Everything is a skill; `WORKFLOW.md` is a pure loader." Each step-owning skill
5
- * self-declares one `when:` over three variables — `work_type`, `workflow`, `step` —
6
- * and owns that step's whole flow in prose. Each skill declares its `inputs` (a short
7
- * description + a default per value); a project overrides a value inline under the
8
- * skill's bullet in `WORKFLOW.md`'s `## Loaded skills` list. Effective value =
9
- * override ?? default.
10
- *
11
- * A **bundle** skill carries a `## Loaded skills` section in its own body and thereby
12
- * loads further skills — the same section, the same bullet syntax, the same parser as
13
- * `WORKFLOW.md`. Nothing in the frontmatter marks it; the section is the marker.
14
- *
15
- * This module is the mechanical enforcement of that model. It PARSES, EXPANDS along
16
- * a fixed value precedence, and VALIDATES — it never dispatches or routes.
17
- * Its only consumers are the contract test suite and `@bakers-build/upgrade-project` /
18
- * `@bakers-build/init-project` load-time validation.
19
- */
20
- import { parse as parseYaml } from 'yaml';
21
- /** The workflow states an agent works — the legal `step` values. */
22
- export const WORKFLOW_STEPS = [
23
- 'qualification',
24
- 'plan',
25
- 'working',
26
- // shortcut: spec/diagnose/coding stay until the skills move to plan/working, then remove them.
27
- 'spec',
28
- 'diagnose',
29
- 'coding',
30
- 'review',
31
- 'pre_deployment',
32
- 'post_deployment',
33
- 'verifying',
34
- 'summary',
35
- ];
36
- /**
37
- * Typed defect raised on the first contract violation.
38
- *
39
- * The single-argument form `new StepContractError(message)` is the norm and stays
40
- * the norm; `details` is opt-in for the few defects a consumer classifies.
41
- */
42
- export class StepContractError extends Error {
43
- /** The defect's classification, when it is one a consumer branches on. */
44
- code;
45
- /**
46
- * For `unresolved_skill`: the skill name that could not be resolved — the root
47
- * bullet's, or the bundle member's. Named `skill` rather than `name` because
48
- * `Error.name` is already taken (and set to `StepContractError` below).
49
- */
50
- skill;
51
- constructor(message, details) {
52
- super(message);
53
- this.name = 'StepContractError';
54
- // `exactOptionalPropertyTypes` is on: assign an optional property only when
55
- // the value is actually defined, never `undefined`.
56
- if (details !== undefined)
57
- this.code = details.code;
58
- if (details?.skill !== undefined)
59
- this.skill = details.skill;
60
- }
61
- }
62
- const LOADED_SKILLS_HEADING = /^##\s+Loaded skills\s*$/;
63
- /**
64
- * One loaded-skill bullet: `- @<namespace>/<skill-name>`, **at indent 0**.
65
- *
66
- * BB-436: the namespace is captured only to be discarded. A skill is addressed
67
- * by NAME — the prefix says which plugin ships it, and a project may load a step
68
- * owner from any plugin (`@designbook-bakers/debo-config-sync`), not just `@bakers-build/`.
69
- * Hardcoding `@bakers-build/` here made every foreign bullet unmatched, and an unmatched
70
- * bullet at indent 0 fell through to the "dedented prose" branch below — dropping
71
- * its own overrides *and* flushing away the preceding bullet's, in silence.
72
- *
73
- * The indent-0 anchor is the second half of the same defect. A load site is a
74
- * top-level list item — in `WORKFLOW.md` and in a bundle body alike — while an
75
- * indented `- @…` line is *value*: `.prompt` block scalars are written as lists
76
- * of required skills. Matching at any indent read such a line as an address,
77
- * which truncated the block scalar mid-value and invented a phantom skill from
78
- * the line, and `validateLoad` then reported that corrupted load as clean.
79
- */
80
- const BULLET = /^-\s+@([a-z0-9-]+)\/([a-z0-9-]+)\s*$/;
81
- /**
82
- * A line merely *shaped* like a bullet — a top-level list item whose content
83
- * starts `@`. Inside `## Loaded skills` such a line is an address, never prose,
84
- * so one that `BULLET` cannot parse is REPORTED (BB-436) instead of taking the
85
- * prose branch. That report is what stops a malformed address from damaging its
86
- * neighbour. It carries `BULLET`'s indent-0 anchor for the same reason: without
87
- * it, a near-miss inside a block scalar — `- @bakers-build/read-ticket before anything
88
- * else.` — failed a document that was correct, and the remedy the message
89
- * prescribes steered the author onto the exact form, which is the silent case.
90
- */
91
- const BULLET_SHAPED = /^-\s+@/;
92
- /**
93
- * Locate the `## Loaded skills` section in a markdown document: the `[start, end)`
94
- * line range of its body, or `null` when the document carries no such section.
95
- *
96
- * The section is the **only** marker of a loader — `WORKFLOW.md` carries one, and so
97
- * does a **bundle** skill whose body loads further skills. Nothing else distinguishes
98
- * the two.
99
- */
100
- function loadedSkillsRange(lines) {
101
- const start = lines.findIndex((l) => LOADED_SKILLS_HEADING.test(l));
102
- if (start === -1)
103
- return null;
104
- let end = lines.length;
105
- for (let i = start + 1; i < lines.length; i++) {
106
- if (/^##\s+/.test(lines[i] ?? '')) {
107
- end = i;
108
- break;
109
- }
110
- }
111
- return [start, end];
112
- }
113
- /**
114
- * Measure a `## Loaded skills` section that yielded no load site, so a consumer
115
- * can say WHICH of the shapes behind `empty_loader` it is looking at.
116
- *
117
- * BB-436: that code covers three documents — a list that is wholly indented, a
118
- * section whose lines are not address-shaped at all (a `*` marker, a `-@` with no
119
- * space), and an empty section. Only the first is the "your list is indented" the
120
- * single message can describe; the other two get a remedy pointing at whitespace
121
- * that is already fine, which is the same misdirection the anchoring fixed.
122
- *
123
- * It lives HERE, not in the CLI that renders it: where the section starts and ends
124
- * and what counts as an address are rules of this module, and a second copy of
125
- * them in the host is exactly the drift this ticket is about. The host measures
126
- * (in `runValidate`) and renders (in `formatReport`); the rule stays in core.
127
- *
128
- * Answers for any document — a section it cannot find simply holds nothing — so a
129
- * caller need not pre-check. It classifies, never throws: the defect is already
130
- * raised by `expandLoad` before this is reached.
131
- */
132
- export function measureEmptyLoader(workflowMd) {
133
- const lines = workflowMd.split('\n');
134
- const range = loadedSkillsRange(lines);
135
- if (range === null)
136
- return { indented: [], bodyLines: 0 };
137
- const [start, end] = range;
138
- const indented = [];
139
- let bodyLines = 0;
140
- for (let i = start + 1; i < end; i++) {
141
- const raw = lines[i] ?? '';
142
- if (raw.trim() === '')
143
- continue;
144
- bodyLines++;
145
- const indent = raw.length - raw.trimStart().length;
146
- // Asks `BULLET_SHAPED` about the DE-indented line rather than restating its
147
- // body with a leading `\s+`: "address-shaped" keeps exactly one definition,
148
- // so this measurement cannot drift from the anchor it is explaining.
149
- if (indent > 0 && BULLET_SHAPED.test(raw.slice(indent))) {
150
- indented.push({ line: i + 1, indent, text: raw.trim() });
151
- }
152
- }
153
- return { indented, bodyLines };
154
- }
155
- /**
156
- * Read the per-skill value **overrides** from a `WORKFLOW.md`'s `## Loaded skills`
157
- * section. A project overrides a skill default **inline under that skill's bullet**
158
- * (indented `key: value` YAML, block scalars allowed), not in one global block.
159
- * Returns a map keyed by skill `name` (any `@<namespace>/` prefix stripped); a skill
160
- * with no overrides maps to `{}` (it runs on its declared defaults).
161
- */
162
- export function parseStepValues(workflowMd) {
163
- return parseLoadedSection(workflowMd).values;
164
- }
165
- /**
166
- * Scan a `## Loaded skills` section once and return both what it lists and what it
167
- * sets. Order and values come from the same pass, so they can never disagree — the
168
- * reason there is no separate name-only parser.
169
- *
170
- * Works on any document carrying the section: a project's `WORKFLOW.md` and a
171
- * **bundle** skill's markdown body are read by the exact same call.
172
- */
173
- function parseLoadedSection(workflowMd) {
174
- const lines = workflowMd.split('\n');
175
- const range = loadedSkillsRange(lines);
176
- if (range === null) {
177
- throw new StepContractError('WORKFLOW.md has no `## Loaded skills` section; a pure loader must carry one.');
178
- }
179
- const [start, end] = range;
180
- const names = [];
181
- const values = {};
182
- let current = null;
183
- let childIndent = null;
184
- let buffer = [];
185
- const flush = () => {
186
- if (current === null)
187
- return;
188
- const yamlText = buffer.join('\n').trim();
189
- if (yamlText === '') {
190
- values[current] = {};
191
- }
192
- else {
193
- let parsed;
194
- try {
195
- parsed = parseYaml(yamlText);
196
- }
197
- catch (cause) {
198
- throw new StepContractError(`inline values for \`${current}\` are not valid YAML: ${cause.message}`);
199
- }
200
- if (parsed == null ||
201
- typeof parsed !== 'object' ||
202
- Array.isArray(parsed)) {
203
- throw new StepContractError(`inline values for \`${current}\` must be a map of value keys to values.`);
204
- }
205
- const inner = {};
206
- for (const [k, v] of Object.entries(parsed)) {
207
- if (typeof v !== 'string') {
208
- throw new StepContractError(`inline value \`${current}.${k}\` must be a string.`);
209
- }
210
- inner[k] = v;
211
- }
212
- values[current] = inner;
213
- }
214
- buffer = [];
215
- childIndent = null;
216
- };
217
- for (const raw of lines.slice(start + 1, end)) {
218
- const bullet = BULLET.exec(raw);
219
- if (bullet) {
220
- flush();
221
- current = bullet[2] ?? null; // group 2 = the name; group 1 = the namespace
222
- if (current !== null)
223
- names.push(current);
224
- continue;
225
- }
226
- if (BULLET_SHAPED.test(raw)) {
227
- // BB-436: a bullet the loader cannot read is a defect in the loader's
228
- // input, not prose. Reporting it here is what keeps the neighbour intact.
229
- throw new StepContractError(`\`## Loaded skills\` carries a bullet this loader cannot read: \`${raw.trim()}\` — write \`- @<namespace>/<skill-name>\`, lowercase with digits and \`-\` only.`);
230
- }
231
- if (current === null)
232
- continue; // prose before the first bullet
233
- if (raw.trim() === '') {
234
- buffer.push('');
235
- continue;
236
- }
237
- const indent = raw.length - raw.trimStart().length;
238
- if (indent === 0) {
239
- // dedented prose after the list ends the current skill's values
240
- flush();
241
- current = null;
242
- continue;
243
- }
244
- if (childIndent === null)
245
- childIndent = indent;
246
- buffer.push(raw.slice(Math.min(childIndent, indent)));
247
- }
248
- flush();
249
- return { names, values };
250
- }
251
- /** Split leading YAML frontmatter (`---` … `---`) from a skill markdown body. */
252
- function readFrontmatter(skillMd) {
253
- const match = /^---\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(skillMd);
254
- if (!match) {
255
- throw new StepContractError('skill markdown has no YAML frontmatter.');
256
- }
257
- let parsed;
258
- try {
259
- parsed = parseYaml(match[1] ?? '');
260
- }
261
- catch (cause) {
262
- throw new StepContractError(`skill frontmatter is not valid YAML: ${cause.message}`);
263
- }
264
- if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
265
- throw new StepContractError('skill frontmatter must be a YAML map.');
266
- }
267
- return parsed;
268
- }
269
- /** Normalize a raw `when` value (string / list / `*` / omitted) to a `WhenValue`. */
270
- function normalizeWhenValue(raw, key) {
271
- if (raw === undefined || raw === null)
272
- return undefined;
273
- if (raw === '*')
274
- return '*';
275
- if (typeof raw === 'string')
276
- return [raw];
277
- if (Array.isArray(raw)) {
278
- if (raw.some((v) => typeof v !== 'string')) {
279
- throw new StepContractError(`\`when.${key}\` list must contain only strings.`);
280
- }
281
- if (raw.length === 0) {
282
- throw new StepContractError(`\`when.${key}\` list must not be empty.`);
283
- }
284
- return raw;
285
- }
286
- throw new StepContractError(`\`when.${key}\` must be a string, a list of strings, \`*\`, or omitted.`);
287
- }
288
- /**
289
- * Parse a skill's frontmatter into its `SkillContract` — the `when` triple (when
290
- * present) and the declared `inputs`. Malformed frontmatter raises `StepContractError`.
291
- */
292
- export function readSkillWhen(skillMd) {
293
- const fm = readFrontmatter(skillMd);
294
- const name = fm.name;
295
- if (typeof name !== 'string' || name.length === 0) {
296
- throw new StepContractError('skill frontmatter must declare a string `name`.');
297
- }
298
- let when;
299
- if (fm.when !== undefined) {
300
- if (fm.when === null ||
301
- typeof fm.when !== 'object' ||
302
- Array.isArray(fm.when)) {
303
- throw new StepContractError(`\`${name}\`: \`when\` must be a map.`);
304
- }
305
- const raw = fm.when;
306
- const allowed = new Set(['work_type', 'workflow', 'step']);
307
- for (const k of Object.keys(raw)) {
308
- if (!allowed.has(k)) {
309
- throw new StepContractError(`\`${name}\`: \`when.${k}\` is not a recognized key (work_type, workflow, step).`);
310
- }
311
- }
312
- const step = normalizeWhenValue(raw.step, 'step');
313
- if (Array.isArray(step)) {
314
- for (const s of step) {
315
- if (!WORKFLOW_STEPS.includes(s)) {
316
- throw new StepContractError(`\`${name}\`: \`when.step\` value \`${s}\` is not a known workflow step.`);
317
- }
318
- }
319
- }
320
- when = {
321
- work_type: normalizeWhenValue(raw.work_type, 'work_type'),
322
- workflow: normalizeWhenValue(raw.workflow, 'workflow'),
323
- step,
324
- };
325
- }
326
- const isBundle = loadedSkillsRange(skillMd.split('\n')) !== null;
327
- if (isBundle) {
328
- if (when !== undefined) {
329
- throw new StepContractError(`\`${name}\`: a bundle loads skills in its body and owns no step — it must declare no \`when\`.`);
330
- }
331
- if (fm.inputs !== undefined) {
332
- throw new StepContractError(`\`${name}\`: a bundle declares no \`inputs\` — it carries values for its members, it takes none itself.`);
333
- }
334
- }
335
- const inputs = readInputs(fm.inputs, name);
336
- const contract = { name, inputs };
337
- if (when !== undefined)
338
- contract.when = when;
339
- if (isBundle) {
340
- const section = parseLoadedSection(skillMd);
341
- if (section.names.length === 0) {
342
- throw new StepContractError(`\`${name}\`: the \`## Loaded skills\` section lists no skill.`);
343
- }
344
- contract.loads = section.names;
345
- contract.values = section.values;
346
- }
347
- return contract;
348
- }
349
- /**
350
- * Parse the `inputs` declaration. Each input is a value key with a short
351
- * `description` and an optional `default` (used when `WORKFLOW.md` supplies no
352
- * override). The map form is canonical:
353
- *
354
- * ```yaml
355
- * inputs:
356
- * test.command:
357
- * description: shell command that runs the e2e suite
358
- * default: ddev bakers-test-e2e
359
- * ```
360
- *
361
- * A bare string value is shorthand for `{ description: <string> }`, and a plain
362
- * list of keys (`inputs: [a, b]`) is accepted for description-less inputs.
363
- */
364
- function readInputs(raw, name) {
365
- if (raw === undefined)
366
- return [];
367
- if (Array.isArray(raw)) {
368
- return raw.map((k) => {
369
- if (typeof k !== 'string') {
370
- throw new StepContractError(`\`${name}\`: \`inputs\` list items must be strings.`);
371
- }
372
- return { key: k };
373
- });
374
- }
375
- if (raw === null || typeof raw !== 'object') {
376
- throw new StepContractError(`\`${name}\`: \`inputs\` must be a map of key → { description, default } (or a list of keys).`);
377
- }
378
- const decls = [];
379
- for (const [key, spec] of Object.entries(raw)) {
380
- if (typeof spec === 'string') {
381
- decls.push({ key, description: spec });
382
- continue;
383
- }
384
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
385
- throw new StepContractError(`\`${name}\`: input \`${key}\` must be a string or a { description, default } map.`);
386
- }
387
- const s = spec;
388
- if (s.description !== undefined && typeof s.description !== 'string') {
389
- throw new StepContractError(`\`${name}\`: input \`${key}\`.description must be a string.`);
390
- }
391
- if (s.default !== undefined && typeof s.default !== 'string') {
392
- throw new StepContractError(`\`${name}\`: input \`${key}\`.default must be a string.`);
393
- }
394
- const decl = { key };
395
- if (typeof s.description === 'string')
396
- decl.description = s.description;
397
- if (typeof s.default === 'string')
398
- decl.default = s.default;
399
- decls.push(decl);
400
- }
401
- return decls;
402
- }
403
- /** Does a normalized `when` value match a triple component? */
404
- function componentMatches(whenVal, tripleVal) {
405
- if (whenVal === undefined || whenVal === '*')
406
- return true;
407
- if (tripleVal === null)
408
- return false;
409
- return whenVal.includes(tripleVal);
410
- }
411
- /** Does a skill's `when` triple match a concrete project triple? */
412
- function matchesTriple(when, triple) {
413
- return (componentMatches(when.work_type, triple.work_type) &&
414
- componentMatches(when.workflow, triple.workflow) &&
415
- componentMatches(when.step, triple.step));
416
- }
417
- function tripleLabel(triple) {
418
- return `(${triple.work_type ?? '—'}, ${triple.workflow}, ${triple.step})`;
419
- }
420
- /**
421
- * The **claimable states** of each bakers.build workflow — the only `step` values a ticket
422
- * in that workflow can ever present to a step owner.
423
- *
424
- * Source of truth: `web/modules/custom/bb_core/bb_core.workflows.yml`. It is
425
- * transcribed here rather than read, because this module is a pure library with no
426
- * filesystem and no Drupal access. A workflow added there must be added here too.
427
- */
428
- export const WORKFLOW_CLAIMABLE_STATES = {
429
- bb_work: ['qualification', 'plan', 'working', 'review'],
430
- bb_deployment: ['pre_deployment', 'post_deployment'],
431
- bb_verification: ['verifying', 'summary'],
432
- };
433
- /**
434
- * Derive the `(work_type, workflow, step)` set a project can produce, from the
435
- * skills it loads plus the fixed workflow/claimable-state table above — the input
436
- * `validateLoad` needs, which until BB-436 only the contract test could build
437
- * (from a hardcoded list) and therefore no shipped command could.
438
- *
439
- * A **work type** exists for the project because some loaded **owner** declares it;
440
- * its participating steps are the union of `when.step` across every owner declaring
441
- * it. Those steps are then crossed against **every** workflow — not merely the
442
- * ones the declaring skill happens to list — because that is what makes a coverage
443
- * gap real rather than tautological: a project that can route `work:code` at
444
- * `working` must be able to route it at `working` in every workflow that claims there.
445
- *
446
- * A step no work type participates in carries a `null` work type (qualification,
447
- * the deployment and verification steps). An owner whose `when.work_type` is `*` or
448
- * omitted is a non-work-typed owner: it contributes to the `null` side, never to a
449
- * named work type. Helpers (no `when`) contribute nothing — they own no step.
450
- *
451
- * **Known blind spot: the work-type universe is derived from the load, not from the
452
- * project.** Because a work type exists here only if some loaded owner declares it,
453
- * deleting the last owner of a work type does not open a coverage gap — it shrinks
454
- * the triple set, and the validation stays green. Removing `- @bakers-build/docs-authoring`
455
- * from this repository's own `WORKFLOW.md` drops one owner and its triples, all
456
- * still covered, although every `work:docs` ticket would then reach `working`
457
- * with no owner at all. The set of work types a project's tickets can actually
458
- * carry lives in the ticket store's `work:*` vocabulary, which this function cannot
459
- * see; closing the gap means feeding that vocabulary in, which is its own change.
460
- * What is validated here is therefore: *given what the project loads, is the load
461
- * internally complete and unambiguous* — not *does the project load everything its
462
- * tickets need*.
463
- */
464
- export function deriveProjectTriples(skills) {
465
- const stepsByWorkType = new Map();
466
- for (const skill of skills) {
467
- const when = skill.when;
468
- if (when === undefined)
469
- continue; // helper: owns no step
470
- const workTypes = when.work_type;
471
- if (workTypes === undefined || workTypes === '*')
472
- continue; // non-work-typed
473
- const steps = when.step === undefined || when.step === '*'
474
- ? WORKFLOW_STEPS
475
- : when.step;
476
- for (const workType of workTypes) {
477
- let set = stepsByWorkType.get(workType);
478
- if (set === undefined) {
479
- set = new Set();
480
- stepsByWorkType.set(workType, set);
481
- }
482
- for (const step of steps)
483
- set.add(step);
484
- }
485
- }
486
- const workTypedSteps = new Set();
487
- for (const set of stepsByWorkType.values())
488
- for (const step of set)
489
- workTypedSteps.add(step);
490
- const triples = [];
491
- const seen = new Set();
492
- const push = (triple) => {
493
- // JSON rather than a joined string: it keeps a `null` work type distinct from
494
- // any string one without inventing a sentinel character that a work type could
495
- // itself contain.
496
- const key = JSON.stringify([
497
- triple.work_type,
498
- triple.workflow,
499
- triple.step,
500
- ]);
501
- if (seen.has(key))
502
- return;
503
- seen.add(key);
504
- triples.push(triple);
505
- };
506
- for (const [workflow, states] of Object.entries(WORKFLOW_CLAIMABLE_STATES)) {
507
- for (const step of states) {
508
- if (!workTypedSteps.has(step)) {
509
- push({ work_type: null, workflow, step });
510
- continue;
511
- }
512
- for (const [workType, steps] of stepsByWorkType) {
513
- if (steps.has(step))
514
- push({ work_type: workType, workflow, step });
515
- }
516
- }
517
- }
518
- return triples;
519
- }
520
- /**
521
- * Read a `WORKFLOW.md` and expand what it loads into the flat, deduplicated list of
522
- * **leaf** skills (step-owners + helpers) the project effectively runs, together with
523
- * the values that reach each leaf. One call is the whole load step: parse, expand,
524
- * resolve — hand the result straight to `validateLoad`.
525
- *
526
- * A **bundle** — a skill whose markdown body carries its own `## Loaded skills`
527
- * section, so its contract carries `loads` — is recursed into, in its declared
528
- * member order; a step-owner or helper is emitted as itself. Leaves are
529
- * deduplicated **by name, first occurrence wins**, so the result is deterministic
530
- * and idempotent — listing the same bundle twice, or a bundle plus one of its
531
- * members as an explicit bullet, yields each leaf exactly once in first-seen order.
532
- *
533
- * **Value precedence**, narrowest wins: the project's own inline override in
534
- * `WORKFLOW.md` beats a bundle's inline value for that member, which beats the
535
- * skill's declared `default` (resolved later, in `validateLoad`). Between nested
536
- * bundles the outermost wins, matching first-occurrence dedup.
537
- *
538
- * A root or member name absent from `byName`, or a cycle in the bundle graph,
539
- * raises a typed `StepContractError`. Bundles may nest (cycle-safe); bakers ships one
540
- * flat level. This never dispatches or routes.
541
- */
542
- export function expandLoad(workflowMd, byName) {
543
- const { names: rootNames, values: workflowValues } = parseLoadedSection(workflowMd);
544
- // BB-436: a `## Loaded skills` section that yields no load site at all is its
545
- // own defect and needs its own message. Expanding it into an empty skill set
546
- // let the failure surface much later as `no skill matches triple (—,
547
- // bb_work, qualification) — coverage gap`, which sends the author looking
548
- // for a missing work-type skill when the real cause is almost always that their
549
- // list is indented — the one shape the indent-0 anchor turns into nothing.
550
- //
551
- // Here rather than in `parseLoadedSection`, because a BUNDLE that lists nothing
552
- // is reported by `readSkillWhen` with the bundle's own name in the message; a
553
- // check in the parser would displace that more specific report.
554
- if (rootNames.length === 0) {
555
- throw new StepContractError('the `## Loaded skills` section carries no load site: write each skill as `- @<namespace>/<skill-name>` at indent 0. An indented `- @…` line is read as value, never as an address.', { code: 'empty_loader' });
556
- }
557
- const skills = [];
558
- const emitted = new Set();
559
- const values = {};
560
- /** Record values for `name` without overwriting a narrower source already set. */
561
- const contribute = (name, from) => {
562
- values[name] ??= {};
563
- const target = values[name];
564
- for (const [k, v] of Object.entries(from)) {
565
- if (!(k in target))
566
- target[k] = v;
567
- }
568
- };
569
- const walk = (name, stack) => {
570
- const skill = byName.get(name);
571
- if (skill === undefined) {
572
- const via = stack.length > 0
573
- ? `bundle \`${stack[stack.length - 1]}\` loads unknown skill \`${name}\``
574
- : `loaded skill \`${name}\` not found`;
575
- // BB-436: both halves are the same class of defect — a name that resolves
576
- // to nothing — and a consumer's remedy for them is identical ("that skill is
577
- // not vendored where the scan looked: pass `--skills`"). They are tagged
578
- // alike, with the unresolved name as data, so the host stops recovering it
579
- // from the message text and stops missing the bundle-member half entirely.
580
- throw new StepContractError(`${via}.`, {
581
- code: 'unresolved_skill',
582
- skill: name,
583
- });
584
- }
585
- if (skill.loads !== undefined) {
586
- if (stack.includes(name)) {
587
- throw new StepContractError(`cyclic skill bundle: ${[...stack, name].join(' → ')}.`);
588
- }
589
- if (Object.keys(workflowValues[name] ?? {}).length > 0) {
590
- throw new StepContractError(`bundle \`${name}\` takes no inline values of its own — write the value under the member skill's own bullet.`);
591
- }
592
- // The bundle's own inline values reach its members, but never beat an
593
- // override the project wrote in WORKFLOW.md (contributed first, below).
594
- for (const [member, memberValues] of Object.entries(skill.values ?? {})) {
595
- contribute(member, memberValues);
596
- }
597
- for (const member of skill.loads)
598
- walk(member, [...stack, name]);
599
- return;
600
- }
601
- if (emitted.has(name))
602
- return;
603
- emitted.add(name);
604
- skills.push(skill);
605
- };
606
- // WORKFLOW.md's own overrides are contributed first, so they win every tie.
607
- for (const [name, own] of Object.entries(workflowValues))
608
- contribute(name, own);
609
- for (const name of rootNames)
610
- walk(name, []);
611
- return { skills, values };
612
- }
613
- /**
614
- * Validate a loaded set of skills against the project's triple set and per-skill values.
615
- *
616
- * For every `(work_type, workflow, step)` the project can produce, **exactly one**
617
- * loaded skill's `when` must match — a triple with no match (gap) or two matches
618
- * (collision) is a configuration error. Every `input` a matched skill declares must
619
- * resolve to a value: the `values` map (as resolved by `expandLoad`) or the input's
620
- * own declared `default`.
621
- *
622
- * Finally (BB-436) every override the load resolved must reach a declared input:
623
- * an override key no loaded skill declares is a defect, because it resolves to
624
- * nothing at all and the skill silently runs on its default instead.
625
- *
626
- * Reports the first defect as a typed `StepContractError`. Never dispatches or routes.
627
- */
628
- export function validateLoad(skills, values, projectTriples) {
629
- const owners = skills.filter((s) => s.when !== undefined);
630
- for (const triple of projectTriples) {
631
- const matched = owners.filter((s) => matchesTriple(s.when, triple));
632
- if (matched.length === 0) {
633
- throw new StepContractError(`no skill matches triple ${tripleLabel(triple)} — coverage gap.`);
634
- }
635
- if (matched.length > 1) {
636
- throw new StepContractError(`${matched.length} skills match triple ${tripleLabel(triple)} — collision: ${matched
637
- .map((s) => s.name)
638
- .join(', ')}.`);
639
- }
640
- const owner = matched[0];
641
- if (owner.inputs.length === 0)
642
- continue;
643
- const overrides = values[owner.name];
644
- for (const input of owner.inputs) {
645
- const effective = overrides?.[input.key] ?? input.default;
646
- if (effective === undefined) {
647
- const desc = input.description ? ` (${input.description})` : '';
648
- throw new StepContractError(`skill \`${owner.name}\` input \`${input.key}\`${desc} has no value: WORKFLOW.md sets no override and the skill declares no default (needed for triple ${tripleLabel(triple)}).`);
649
- }
650
- }
651
- }
652
- // BB-436: the loop above reads overrides only through the DECLARED inputs of a
653
- // matched owner (`overrides?.[input.key]`), so it never looks at an override key
654
- // the skill does not declare. Such a key resolves to nothing — the skill runs on
655
- // its default and the project's stated intent evaporates without a word. That is
656
- // how a key rename silently degraded eleven overrides on a consumer project for
657
- // six weeks. Sweep the loaded skills for it, and name both skill and key.
658
- //
659
- // Deliberately AFTER the triple loop, so a coverage gap or a collision is still
660
- // reported first with its own message; and over the `skills` LIST rather than the
661
- // `values` map, because `values` may legitimately carry entries for skills this
662
- // load does not contain. Owners and helpers alike: a helper is loaded precisely
663
- // for its inputs, so a dead override key on one is the same defect.
664
- for (const skill of skills) {
665
- const overrides = values[skill.name];
666
- if (overrides === undefined)
667
- continue;
668
- const declared = new Set(skill.inputs.map((i) => i.key));
669
- for (const key of Object.keys(overrides)) {
670
- if (declared.has(key))
671
- continue;
672
- const known = declared.size === 0
673
- ? 'the skill declares no inputs at all'
674
- : `the skill declares \`${[...declared].join('`, `')}\``;
675
- throw new StepContractError(`skill \`${skill.name}\` has no input \`${key}\`: the override resolves to nothing and the skill silently runs on its default — ${known}. Rename the override to a declared key rather than deleting it.`);
676
- }
677
- }
678
- }