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