ultra11y 2.22.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/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- package/src/wcag.ts +128 -0
package/src/name.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Accessible-name computation — a pragmatic subset of the ARIA accname algorithm,
|
|
2
|
+
// enough for the link/button/image/control naming rules. Not a full implementation
|
|
3
|
+
// (no rendered CSS), so it stays conservative: it only claims a name it can see.
|
|
4
|
+
import type { Doc, El, HNode } from "./parse/html.js";
|
|
5
|
+
import { attr, hasAttr, boundAttr, hasBoundAttr, descendants, ancestors } from "./parse/html.js";
|
|
6
|
+
import { isIntrinsic } from "./parse/jsx-bridge.js";
|
|
7
|
+
|
|
8
|
+
const collapse = (s: string): string => s.replace(/\s+/g, " ").trim();
|
|
9
|
+
|
|
10
|
+
/** JSX/SFC: the element's content/name may be injected at runtime in a way the static
|
|
11
|
+
* parse can't see — a `<slot>` projection or a child component (which can render
|
|
12
|
+
* anything). Rules that assert "this element is empty / unnamed" must bail on these. */
|
|
13
|
+
export function mayInjectContent(el: El): boolean {
|
|
14
|
+
return descendants(el).some((d) => d.tag === "slot" || (d.tag !== "#fragment" && !isIntrinsic(d.tag)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Inline style declarations that take an element (and its subtree) out of the
|
|
18
|
+
// accessibility tree entirely. Only INLINE styles are visible to a static parse — a
|
|
19
|
+
// class-driven `display:none` is invisible here, which is why this stays a narrow,
|
|
20
|
+
// high-confidence suppression rather than a general visibility model.
|
|
21
|
+
const HIDDEN_STYLE = /(^|;)\s*(display\s*:\s*none|visibility\s*:\s*(hidden|collapse))\s*(;|$)/i;
|
|
22
|
+
|
|
23
|
+
/** Is the element not rendered at all — `[hidden]`, or an inline `display:none` /
|
|
24
|
+
* `visibility:hidden` on itself or an ancestor? Such an element is in no focus order and
|
|
25
|
+
* exposes nothing, whatever its ARIA says. */
|
|
26
|
+
export function isDisplayHidden(el: El): boolean {
|
|
27
|
+
for (const node of [el, ...ancestors(el)]) {
|
|
28
|
+
if (hasAttr(node, "hidden")) return true;
|
|
29
|
+
if (HIDDEN_STYLE.test(attr(node, "style") ?? "")) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Is this element removed from the accessibility tree by something the source shows —
|
|
35
|
+
* `hidden`, an inline `display:none`/`visibility:hidden`, or an `aria-hidden="true"`
|
|
36
|
+
* ancestor? Such an element exposes no name to anyone, so a rule demanding one would be
|
|
37
|
+
* reporting a defect nobody can experience. (`aria-hidden` on the element ITSELF is
|
|
38
|
+
* included; a rule whose subject IS aria-hidden must use `isDisplayHidden` instead.) */
|
|
39
|
+
export function isHiddenFromAT(el: El): boolean {
|
|
40
|
+
if (isDisplayHidden(el)) return true;
|
|
41
|
+
return [el, ...ancestors(el)].some((node) => attr(node, "aria-hidden") === "true");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `role="presentation"`/`role="none"` — the author has explicitly removed the element's
|
|
45
|
+
* semantics, so it exposes no name to remove. */
|
|
46
|
+
export function isPresentational(el: El): boolean {
|
|
47
|
+
return ["presentation", "none"].includes((attr(el, "role") ?? "").trim().toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Should a rule that demands an accessible name stay silent on this element? True when
|
|
51
|
+
* the element is not in the accessibility tree at all, or is explicitly presentational.
|
|
52
|
+
* Reporting either would be a defect no user can experience — the precision failure the
|
|
53
|
+
* W3C ACT corpus catches most often. */
|
|
54
|
+
export function isNameExempt(el: El): boolean {
|
|
55
|
+
return isHiddenFromAT(el) || isPresentational(el);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The name an embedded image contributes to its container's accessible name. Per the
|
|
59
|
+
* accname computation an `<img>` is named by aria-labelledby > aria-label > alt > title,
|
|
60
|
+
* so a link wrapping `<img aria-label="…">` or `<img title="…">` is NOT unnamed.
|
|
61
|
+
* `doc` resolves an aria-labelledby to the text it points at; without one, the mere
|
|
62
|
+
* presence of the attribute still counts as "named" (the reference may resolve at
|
|
63
|
+
* runtime, and aria-ref-missing-id owns the dangling-idref case). */
|
|
64
|
+
function embeddedImageName(n: El, doc?: Doc): string {
|
|
65
|
+
const labelled = (attr(n, "aria-labelledby") ?? "").trim();
|
|
66
|
+
if (labelled) return doc ? ariaLabelledbyText(n, doc) || labelled : labelled;
|
|
67
|
+
return (boundAttr(n, "aria-label") ?? attr(n, "alt") ?? attr(n, "title") ?? "").trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Text content of a subtree, with <img> names and <svg><title> folded in. */
|
|
71
|
+
function nameFromContent(el: El, doc?: Doc): string {
|
|
72
|
+
let out = "";
|
|
73
|
+
const walk = (n: HNode): void => {
|
|
74
|
+
if (n.type === "text") {
|
|
75
|
+
out += n.data;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (n.tag === "img") {
|
|
79
|
+
const a = embeddedImageName(n, doc);
|
|
80
|
+
if (a) out += " " + a;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (n.tag === "svg") {
|
|
84
|
+
const title = descendants(n).find((d) => d.tag === "title");
|
|
85
|
+
if (title) out += " " + nameFromContent(title, doc);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// aria-hidden subtrees contribute nothing
|
|
89
|
+
if (attr(n, "aria-hidden") === "true") return;
|
|
90
|
+
for (const c of n.children) walk(c);
|
|
91
|
+
};
|
|
92
|
+
for (const c of el.children) walk(c);
|
|
93
|
+
return collapse(out);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ariaLabelledbyText(el: El, doc: Doc): string {
|
|
97
|
+
const ids = attr(el, "aria-labelledby");
|
|
98
|
+
if (!ids) return "";
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
for (const id of ids.split(/\s+/).filter(Boolean)) {
|
|
101
|
+
const ref = doc.byId.get(id);
|
|
102
|
+
if (ref) parts.push(nameFromContent(ref) || (attr(ref, "aria-label") ?? "").trim());
|
|
103
|
+
}
|
|
104
|
+
return collapse(parts.join(" "));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const BUTTON_INPUT = new Set(["button", "submit", "reset"]);
|
|
108
|
+
const NAMELESS_BY_DEFAULT = new Set(["submit", "reset"]); // UA supplies a default label
|
|
109
|
+
|
|
110
|
+
/** Compute the accessible name of an element (links, buttons, images, generic). */
|
|
111
|
+
export function accessibleName(el: El, doc: Doc): string {
|
|
112
|
+
// 1. aria-labelledby (a dynamically-bound :aria-labelledby can't be resolved, but it
|
|
113
|
+
// names the element — treat as present so we don't hallucinate a missing name)
|
|
114
|
+
const labelledby = ariaLabelledbyText(el, doc);
|
|
115
|
+
if (labelledby) return labelledby;
|
|
116
|
+
if (hasBoundAttr(el, "aria-labelledby") && !attr(el, "aria-labelledby")) return " ";
|
|
117
|
+
// 2. aria-label (incl. dynamic `:aria-label`/`v-bind:` binding → value unknown but present)
|
|
118
|
+
const ariaLabel = (boundAttr(el, "aria-label") ?? "").trim();
|
|
119
|
+
if (ariaLabel) return ariaLabel;
|
|
120
|
+
// 3. element-specific
|
|
121
|
+
if (el.tag === "img" || el.tag === "area") {
|
|
122
|
+
return (boundAttr(el, "alt") ?? "").trim(); // alt="" → intentional empty name; :alt="x" → present
|
|
123
|
+
}
|
|
124
|
+
if (el.tag === "input") {
|
|
125
|
+
const type = (attr(el, "type") ?? "text").toLowerCase();
|
|
126
|
+
if (BUTTON_INPUT.has(type)) {
|
|
127
|
+
const value = (attr(el, "value") ?? "").trim();
|
|
128
|
+
if (value) return value;
|
|
129
|
+
if (NAMELESS_BY_DEFAULT.has(type)) return type === "submit" ? "Submit" : "Reset";
|
|
130
|
+
return (attr(el, "title") ?? "").trim();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// 4. content + title fallback
|
|
134
|
+
const content = nameFromContent(el, doc);
|
|
135
|
+
if (content) return content;
|
|
136
|
+
return (attr(el, "title") ?? "").trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FIELD_TAGS = new Set(["input", "select", "textarea"]);
|
|
140
|
+
const NON_LABELABLE_INPUT = new Set(["hidden", "submit", "reset", "button", "image"]);
|
|
141
|
+
|
|
142
|
+
/** Is this a labelable form field (excludes buttons/hidden)? */
|
|
143
|
+
export function isFormField(el: El): boolean {
|
|
144
|
+
if (!FIELD_TAGS.has(el.tag)) return false;
|
|
145
|
+
if (hasAttr(el, "hidden")) return false; // a [hidden] field is not an exposed UI control
|
|
146
|
+
if (el.tag === "input") {
|
|
147
|
+
const type = (attr(el, "type") ?? "text").toLowerCase();
|
|
148
|
+
return !NON_LABELABLE_INPUT.has(type);
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface LabelInfo {
|
|
154
|
+
hasLabel: boolean;
|
|
155
|
+
via: "for" | "wrapping" | "aria-labelledby" | "aria-label" | "title" | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Does a form field have a programmatic label/name? (placeholder does NOT count.) */
|
|
159
|
+
export function controlLabel(el: El, doc: Doc): LabelInfo {
|
|
160
|
+
// Mirror accessibleName's guard: a labelledby is a real label only when it resolves to
|
|
161
|
+
// text, OR is a DYNAMIC binding (present but value unknown). A literal aria-labelledby
|
|
162
|
+
// whose target is missing/empty names nothing — the field is effectively unlabeled.
|
|
163
|
+
if ((attr(el, "aria-labelledby") && ariaLabelledbyText(el, doc)) || (hasBoundAttr(el, "aria-labelledby") && !attr(el, "aria-labelledby")))
|
|
164
|
+
return { hasLabel: true, via: "aria-labelledby" };
|
|
165
|
+
if ((boundAttr(el, "aria-label") ?? "").trim()) return { hasLabel: true, via: "aria-label" };
|
|
166
|
+
const id = attr(el, "id");
|
|
167
|
+
if (id) {
|
|
168
|
+
const lbl = doc.elements.find((e) => e.tag === "label" && attr(e, "for") === id);
|
|
169
|
+
if (lbl) return { hasLabel: true, via: "for" };
|
|
170
|
+
}
|
|
171
|
+
if (ancestors(el).some((a) => a.tag === "label")) return { hasLabel: true, via: "wrapping" };
|
|
172
|
+
if ((attr(el, "title") ?? "").trim()) return { hasLabel: true, via: "title" };
|
|
173
|
+
return { hasLabel: false, via: null };
|
|
174
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { PhaseInfo } from "./orchestrate.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Templates for `ultra11y orchestrate` — the generator that turns the run's
|
|
6
|
+
// CURRENT worklists into a launchable multi-agent Workflow per phase, the
|
|
7
|
+
// dispatch contracts it references, and a sequential RUNBOOK fallback.
|
|
8
|
+
// Everything here is emitted by string concatenation with the run's constants
|
|
9
|
+
// injected as JSON literals, so the workflow runs as-is under the Workflow
|
|
10
|
+
// tool: `export const meta` stays a pure literal, and no emitted line ever
|
|
11
|
+
// calls Date.now()/Math.random()/new Date() (they throw in that harness).
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Family-standard footer: subagents return fragments; the orchestrator is the sole writer. */
|
|
15
|
+
const ONE_WRITER_FOOTER = `
|
|
16
|
+
## Return, don't write
|
|
17
|
+
|
|
18
|
+
Return ONLY the structured output specified above. Do NOT write, edit, or delete any file; do NOT run any engine command that writes (\`verify --apply\`, \`fix --write\`, \`audit --out\`, \`init\`). The orchestrator is the sole writer — it folds your verdicts into the worklist itself and runs the apply gate. Exception: if a justification is prose too large to return, write ONLY to \`<RUN>/orchestration/out/<role>-<batch>.md\` (a file namespaced to you alone) and return its path.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
// Structured-output schemas the emitted workflows pass to agent(..., { schema }).
|
|
22
|
+
// They mirror the fail-closed rules `verify --apply` enforces, so a fragment that
|
|
23
|
+
// validates here still gets re-checked (grounding, required justifications) at fold time.
|
|
24
|
+
const ADJUDICATE_SCHEMA = {
|
|
25
|
+
type: "object",
|
|
26
|
+
required: ["verdicts"],
|
|
27
|
+
properties: {
|
|
28
|
+
verdicts: {
|
|
29
|
+
type: "array",
|
|
30
|
+
items: {
|
|
31
|
+
type: "object",
|
|
32
|
+
required: ["criteriaId", "verdict"],
|
|
33
|
+
properties: {
|
|
34
|
+
criteriaId: { type: "string" },
|
|
35
|
+
verdict: { enum: ["C", "NC", "NA", "manual"] },
|
|
36
|
+
justification: { type: "string", description: "REQUIRED for C and NA" },
|
|
37
|
+
reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
|
|
38
|
+
findings: {
|
|
39
|
+
type: "array",
|
|
40
|
+
description: "REQUIRED (>=1, groundable) for NC",
|
|
41
|
+
items: {
|
|
42
|
+
type: "object",
|
|
43
|
+
required: ["file", "line", "message"],
|
|
44
|
+
properties: {
|
|
45
|
+
file: { type: "string" },
|
|
46
|
+
line: { type: "integer" },
|
|
47
|
+
selector: { type: "string" },
|
|
48
|
+
message: { type: "string" },
|
|
49
|
+
snippet: { type: "string" },
|
|
50
|
+
severity: { enum: ["bloquant", "majeur", "mineur"] },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const VERIFY_SCHEMA = {
|
|
61
|
+
type: "object",
|
|
62
|
+
required: ["verdicts"],
|
|
63
|
+
properties: {
|
|
64
|
+
verdicts: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: {
|
|
67
|
+
type: "object",
|
|
68
|
+
required: ["n", "verdict", "note"],
|
|
69
|
+
properties: {
|
|
70
|
+
n: { type: "integer" },
|
|
71
|
+
verdict: { enum: ["supported", "partial", "refuted", "unsupported"] },
|
|
72
|
+
note: { type: "string", description: "one line grounded in the source you read" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface PhaseSpec {
|
|
80
|
+
role: string;
|
|
81
|
+
title: string;
|
|
82
|
+
schema: unknown;
|
|
83
|
+
description: (items: number) => string;
|
|
84
|
+
/** The orchestrator's fold step, shown as a comment in the workflow tail + in the runbook. */
|
|
85
|
+
applyHint: (engineAbs: string, worklist: string, runAbs: string) => string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PHASE_SPECS: Record<string, PhaseSpec> = {
|
|
89
|
+
adjudicate: {
|
|
90
|
+
role: "adjudicator",
|
|
91
|
+
title: "Adjudicate",
|
|
92
|
+
schema: ADJUDICATE_SCHEMA,
|
|
93
|
+
description: (n) => `Adjudicate the ${n} residual judgment criterion(ia) of an ultra11y audit (fan-out, fail-closed fold)`,
|
|
94
|
+
applyHint: (engine, worklist, run) => `node ${engine} verify --apply ${worklist} --in ${join(run, "audit-latest.json")} --out ${run}`,
|
|
95
|
+
},
|
|
96
|
+
"verify-report": {
|
|
97
|
+
role: "refuter",
|
|
98
|
+
title: "Verify",
|
|
99
|
+
schema: VERIFY_SCHEMA,
|
|
100
|
+
description: (n) => `Adversarially verify the ${n} non-conformity claim(s) of an ultra11y report (skeptic fan-out)`,
|
|
101
|
+
applyHint: (engine, worklist) => `node ${engine} verify --apply ${worklist} --report <report.md>`,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function phaseSpec(name: string): PhaseSpec {
|
|
106
|
+
const spec = PHASE_SPECS[name];
|
|
107
|
+
if (!spec) throw new Error(`no phase spec for "${name}"`);
|
|
108
|
+
return spec;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Chunk worklist ids into batches, one subagent per batch (order-preserving, deterministic). */
|
|
112
|
+
export function toBatches(ids: string[], batchSize: number): string[][] {
|
|
113
|
+
const out: string[][] = [];
|
|
114
|
+
for (let i = 0; i < ids.length; i += batchSize) out.push(ids.slice(i, i + batchSize));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function phaseWorkflowScript(ph: PhaseInfo, runAbs: string, engineAbs: string, batchSize: number): string {
|
|
119
|
+
const spec = phaseSpec(ph.name);
|
|
120
|
+
const scriptPath = join(runAbs, "orchestration", `${ph.name}.workflow.mjs`);
|
|
121
|
+
const meta = { name: `ultra11y-${ph.name}`, description: spec.description(ph.items), phases: [{ title: spec.title }] };
|
|
122
|
+
return [
|
|
123
|
+
`export const meta = ${JSON.stringify(meta)}`,
|
|
124
|
+
``,
|
|
125
|
+
`// NOT a plain Node script: launch via the Workflow tool — Workflow({ scriptPath: ${JSON.stringify(scriptPath)} }).`,
|
|
126
|
+
`// Emitted by \`ultra11y orchestrate\` from the CURRENT worklist. The worklist is the source`,
|
|
127
|
+
`// of truth: if it changes, re-run \`orchestrate --phase ${ph.name}\` before launching.`,
|
|
128
|
+
``,
|
|
129
|
+
`// Constants for THIS run (injected at emit time; no Date.now/Math.random in this harness).`,
|
|
130
|
+
`const RUN = ${JSON.stringify(runAbs)}`,
|
|
131
|
+
`const ENGINE = ${JSON.stringify(engineAbs)}`,
|
|
132
|
+
`const WORKLIST = ${JSON.stringify(ph.worklist)}`,
|
|
133
|
+
`const AGENTS = RUN + '/orchestration/agents'`,
|
|
134
|
+
`const BATCHES = ${JSON.stringify(toBatches(ph.ids, batchSize))}`,
|
|
135
|
+
`const SCHEMA = ${JSON.stringify(spec.schema)}`,
|
|
136
|
+
``,
|
|
137
|
+
`function contract(name, extra) {`,
|
|
138
|
+
` return 'Read and follow the dispatch contract at ' + AGENTS + '/' + name + '.md VERBATIM.\\n'`,
|
|
139
|
+
` + 'Constants: RUN=' + RUN + ' ENGINE=' + ENGINE + ' WORKLIST=' + WORKLIST + '.\\n'`,
|
|
140
|
+
` + 'Invoke the engine only by its ABSOLUTE path: node ' + ENGINE + ' <cmd> — read-only commands only.'`,
|
|
141
|
+
` + (extra ? '\\n' + extra : '')`,
|
|
142
|
+
`}`,
|
|
143
|
+
``,
|
|
144
|
+
`log('ultra11y ${ph.name}: ' + ${JSON.stringify(String(ph.items))} + ' item(s) across ' + BATCHES.length + ' agent(s)')`,
|
|
145
|
+
``,
|
|
146
|
+
`phase(${JSON.stringify(spec.title)})`,
|
|
147
|
+
`const results = await pipeline(BATCHES, (batch, _item, i) =>`,
|
|
148
|
+
` agent(contract('${spec.role}', 'ITEMS=' + batch.join(',')), { label: '${ph.name}:' + (i + 1), phase: ${JSON.stringify(spec.title)}, agentType: 'general-purpose', schema: SCHEMA }))`,
|
|
149
|
+
``,
|
|
150
|
+
`// One-writer rule: this workflow only COLLECTS verdict fragments. The main agent folds`,
|
|
151
|
+
`// them into WORKLIST (fill each item's fields from the matching fragment), then runs:`,
|
|
152
|
+
`// ${spec.applyHint(engineAbs, ph.worklist, runAbs)}`,
|
|
153
|
+
`return { phase: ${JSON.stringify(ph.name)}, worklist: WORKLIST, results: results.filter(Boolean) }`,
|
|
154
|
+
``,
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function agentContracts(runAbs: string, engineAbs: string): Record<string, string> {
|
|
159
|
+
const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
|
|
160
|
+
return {
|
|
161
|
+
adjudicator: `# Contract: adjudicator
|
|
162
|
+
|
|
163
|
+
You adjudicate residual judgment criteria of an ultra11y WCAG audit — the success criteria the deterministic engine could not decide (alt-text relevance, link purpose in context, reading order…).
|
|
164
|
+
|
|
165
|
+
Worklist: \`${join(runAbs, "ADJUDICATE.todo.json")}\` (an object with \`kind: "adjudication"\` and \`items[]\`). Handle ONLY the criteria whose \`criteriaId\` is named in your prompt (\`ITEMS=<id,…>\`).
|
|
166
|
+
|
|
167
|
+
For EACH of your criteria:
|
|
168
|
+
|
|
169
|
+
1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code — open the cited files at the cited lines whenever the snippet alone cannot decide.
|
|
170
|
+
2. Rule it (the apply gate is FAIL-CLOSED — a verdict missing its required field does not fold):
|
|
171
|
+
- \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
|
|
172
|
+
- \`NC\` (non-conforming) — REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity? }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected.
|
|
173
|
+
- \`NA\` (not applicable) — REQUIRES \`justification\`.
|
|
174
|
+
- \`manual\` (still undecidable) — REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
|
|
175
|
+
3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason — that is a valid, honest verdict; the scan tier or a human picks it up.
|
|
176
|
+
|
|
177
|
+
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read.
|
|
178
|
+
${footer}`,
|
|
179
|
+
refuter: `# Contract: refuter
|
|
180
|
+
|
|
181
|
+
You are an adversarial skeptic verifying the non-conformities of an ultra11y report. Your job is to try to REFUTE each claim: assume it is wrong until the source proves it.
|
|
182
|
+
|
|
183
|
+
Worklist: \`${join(runAbs, "VERIFY.todo.json")}\` (a JSON array; each entry has \`n\`, \`criteriaId\`, \`file\`, \`line\`, \`selector\`, \`claim\`). Handle ONLY the entries whose \`n\` is named in your prompt (\`ITEMS=<n,…>\`).
|
|
184
|
+
|
|
185
|
+
For EACH of your entries:
|
|
186
|
+
|
|
187
|
+
1. Open \`file\` at \`line\` and read the cited element (\`selector\`) in its real context.
|
|
188
|
+
2. Judge the claim against the source:
|
|
189
|
+
- \`supported\` — the cited code violates the criterion exactly as claimed.
|
|
190
|
+
- \`partial\` — a real issue, but the claim overstates it (wrong element, wrong scope, exaggerated count).
|
|
191
|
+
- \`unsupported\` — the source does not establish the claim.
|
|
192
|
+
- \`refuted\` — the source contradicts the claim.
|
|
193
|
+
When unsure, choose the HARSHER verdict — a false pass is worse than a false fail.
|
|
194
|
+
3. \`note\` is REQUIRED — one line grounded in what you read (quote or paraphrase the decisive code).
|
|
195
|
+
|
|
196
|
+
Return (structured output): \`{ "verdicts": [{ "n", "verdict", "note" }] }\` — your ITEMS only.
|
|
197
|
+
${footer}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function runbookMd(phases: PhaseInfo[], runAbs: string, engineAbs: string): string {
|
|
202
|
+
const status = phases
|
|
203
|
+
.map((p) => `| ${p.name} | \`${p.worklist}\` | ${p.ready ? `ready (${p.items} item(s))` : "not ready"} | \`${p.prerequisite}\` |`)
|
|
204
|
+
.join("\n");
|
|
205
|
+
const engine = `node ${engineAbs}`;
|
|
206
|
+
return `# ultra11y — sequential RUNBOOK (eco / no-subagent fallback)
|
|
207
|
+
|
|
208
|
+
Run: \`${runAbs}\` · Engine: \`${engine}\`
|
|
209
|
+
|
|
210
|
+
Generated by \`ultra11y orchestrate\` from the CURRENT run state. This sequential path is
|
|
211
|
+
correctness-identical to the multi-agent workflows — same worklists, same contracts, same
|
|
212
|
+
fail-closed gates; only wall-clock differs. Fan-out is an optimization, not a requirement.
|
|
213
|
+
|
|
214
|
+
## Phase status
|
|
215
|
+
|
|
216
|
+
| Phase | Worklist | Status | Produce it with |
|
|
217
|
+
|---|---|---|---|
|
|
218
|
+
${status}
|
|
219
|
+
|
|
220
|
+
## The loop (play every role yourself, one item at a time)
|
|
221
|
+
|
|
222
|
+
1. **Audit** (if not done): \`${engine} audit "<globs>" --graph --out ${runAbs}\` → \`${join(runAbs, "audit-latest.json")}\`.
|
|
223
|
+
2. **Adjudicate the residual criteria** — \`${engine} verify --manual --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\` writes \`${join(runAbs, "ADJUDICATE.todo.json")}\`. For EVERY item, apply \`${join(runAbs, "orchestration", "agents", "adjudicator.md")}\` yourself (read the evidence, rule C/NC/NA/manual, fill the required justification/findings/reason IN the todo file). Then fold, fail-closed: \`${engine} verify --apply ${join(runAbs, "ADJUDICATE.todo.json")} --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\`.
|
|
224
|
+
3. **Report**: \`${engine} report --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\`.
|
|
225
|
+
4. **Verify the report's claims** — \`${engine} verify --report <the report .md> --out ${runAbs}\` writes \`${join(runAbs, "VERIFY.todo.json")}\`. For EVERY entry, apply \`${join(runAbs, "orchestration", "agents", "refuter.md")}\` yourself (open file:line, verdict supported/partial/refuted/unsupported + note IN the todo file). Then: \`${engine} verify --apply ${join(runAbs, "VERIFY.todo.json")} --report <the report .md>\`.
|
|
226
|
+
5. **Gate**: \`${engine} check --report <the report .md> --semantic\` must exit 0 before presenting anything.
|
|
227
|
+
6. **Fix & re-audit**: \`${engine} fix <globs> --write --iterate\`, hand-apply the judgment fixes, then loop from step 1 until the gate stays green.
|
|
228
|
+
|
|
229
|
+
With subagents available, prefer the emitted workflows instead: \`orchestrate --run ${runAbs} --phase <p>\` then \`Workflow({ scriptPath: "${join(runAbs, "orchestration", "<p>.workflow.mjs")}" })\` — you stay the sole writer either way.
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { AdjudicationFile } from "./adjudicate.js";
|
|
4
|
+
import { agentContracts, phaseWorkflowScript, runbookMd } from "./orchestrate-templates.js";
|
|
5
|
+
import type { VerifyItem } from "./verify.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// `ultra11y orchestrate` — emit the run's multi-agent orchestration from its
|
|
9
|
+
// CURRENT worklists (per-phase workflow scripts + dispatch contracts + a
|
|
10
|
+
// sequential RUNBOOK), so a subagent-capable harness fans the judgment work
|
|
11
|
+
// out while the main agent stays the sole writer. Per-phase emission is
|
|
12
|
+
// deliberate: each worklist only exists after its engine step (`verify
|
|
13
|
+
// --manual`, `verify --report`), so a whole-pipeline script could only carry
|
|
14
|
+
// placeholders — exactly what the check/verify gates exist to prevent.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export const PHASES = ["adjudicate", "verify-report"] as const;
|
|
18
|
+
export type PhaseName = (typeof PHASES)[number];
|
|
19
|
+
|
|
20
|
+
/** Small worklists don't amortize a fan-out — orchestrate says so and nudges --eco. */
|
|
21
|
+
export const SMALL_WORKLIST = 3;
|
|
22
|
+
/** One subagent per batch of at most this many worklist items. */
|
|
23
|
+
export const BATCH_SIZE = 8;
|
|
24
|
+
|
|
25
|
+
export interface PhaseInfo {
|
|
26
|
+
name: PhaseName;
|
|
27
|
+
ready: boolean;
|
|
28
|
+
/** Absolute path of the worklist this phase fans out over. */
|
|
29
|
+
worklist: string;
|
|
30
|
+
items: number;
|
|
31
|
+
/** The injected fan-out ids (criteriaId for adjudicate, `n` for verify-report). */
|
|
32
|
+
ids: string[];
|
|
33
|
+
/** The engine command that produces the worklist when it is missing. */
|
|
34
|
+
prerequisite: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function listPhases(runDir: string, engineAbs: string): PhaseInfo[] {
|
|
38
|
+
const run = resolve(runDir);
|
|
39
|
+
|
|
40
|
+
const adjPath = join(run, "ADJUDICATE.todo.json");
|
|
41
|
+
let adjIds: string[] = [];
|
|
42
|
+
let adjReady = false;
|
|
43
|
+
if (existsSync(adjPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const f = JSON.parse(readFileSync(adjPath, "utf8")) as AdjudicationFile;
|
|
46
|
+
if (f && f.kind === "adjudication" && Array.isArray(f.items)) {
|
|
47
|
+
adjReady = true;
|
|
48
|
+
adjIds = f.items.map((i) => i.criteriaId);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* unreadable worklist = not ready */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const verPath = join(run, "VERIFY.todo.json");
|
|
56
|
+
let verIds: string[] = [];
|
|
57
|
+
let verReady = false;
|
|
58
|
+
if (existsSync(verPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const items = JSON.parse(readFileSync(verPath, "utf8")) as VerifyItem[];
|
|
61
|
+
if (Array.isArray(items)) {
|
|
62
|
+
verReady = true;
|
|
63
|
+
verIds = items.map((i) => String(i.n));
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
/* unreadable worklist = not ready */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
name: "adjudicate",
|
|
73
|
+
ready: adjReady,
|
|
74
|
+
worklist: adjPath,
|
|
75
|
+
items: adjIds.length,
|
|
76
|
+
ids: adjIds,
|
|
77
|
+
prerequisite: `node ${engineAbs} verify --manual --in ${join(run, "audit-latest.json")} --out ${run}`,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "verify-report",
|
|
81
|
+
ready: verReady,
|
|
82
|
+
worklist: verPath,
|
|
83
|
+
items: verIds.length,
|
|
84
|
+
ids: verIds,
|
|
85
|
+
prerequisite: `node ${engineAbs} verify --report <report.md> --out ${run}`,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface OrchestrateOptions {
|
|
91
|
+
/** Emit only this phase (exit 2 if its worklist does not exist yet). */
|
|
92
|
+
phase?: string;
|
|
93
|
+
/** Emit only the RUNBOOK + contracts (the explicit low-token sequential path). */
|
|
94
|
+
eco?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface OrchestrateResult {
|
|
98
|
+
exitCode: number;
|
|
99
|
+
written: string[];
|
|
100
|
+
notices: string[];
|
|
101
|
+
errors: string[];
|
|
102
|
+
phases: PhaseInfo[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function orchestrateRun(runDir: string, engineAbs: string, opts: OrchestrateOptions = {}): OrchestrateResult {
|
|
106
|
+
const run = resolve(runDir);
|
|
107
|
+
if (!existsSync(run)) {
|
|
108
|
+
return { exitCode: 2, written: [], notices: [], errors: [`run dir not found: ${run}`], phases: [] };
|
|
109
|
+
}
|
|
110
|
+
const phases = listPhases(run, engineAbs);
|
|
111
|
+
|
|
112
|
+
let selected = phases.filter((p) => p.ready);
|
|
113
|
+
if (opts.phase !== undefined) {
|
|
114
|
+
const ph = phases.find((p) => p.name === opts.phase);
|
|
115
|
+
if (!ph) {
|
|
116
|
+
return {
|
|
117
|
+
exitCode: 2,
|
|
118
|
+
written: [],
|
|
119
|
+
notices: [],
|
|
120
|
+
errors: [`unknown phase "${opts.phase}" — expected one of: ${PHASES.join(", ")}.`],
|
|
121
|
+
phases,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (!ph.ready) {
|
|
125
|
+
return {
|
|
126
|
+
exitCode: 2,
|
|
127
|
+
written: [],
|
|
128
|
+
notices: [],
|
|
129
|
+
errors: [`phase "${ph.name}" is not ready — its worklist ${ph.worklist} does not exist yet. Produce it first: ${ph.prerequisite}`],
|
|
130
|
+
phases,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
selected = [ph];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const orchDir = join(run, "orchestration");
|
|
137
|
+
const agentsDir = join(orchDir, "agents");
|
|
138
|
+
mkdirSync(join(orchDir, "out"), { recursive: true });
|
|
139
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const written: string[] = [];
|
|
142
|
+
const notices: string[] = [];
|
|
143
|
+
|
|
144
|
+
// Contracts: every role, every call (idempotent overwrite) — they double as the
|
|
145
|
+
// RUNBOOK's self-pass checklists, so eco mode needs them too.
|
|
146
|
+
for (const [name, content] of Object.entries(agentContracts(run, engineAbs))) {
|
|
147
|
+
const p = join(agentsDir, `${name}.md`);
|
|
148
|
+
writeFileSync(p, content);
|
|
149
|
+
written.push(p);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Reconcile before emitting: a workflow script left behind by an earlier
|
|
153
|
+
// emission whose worklist has since emptied or disappeared is a launchable
|
|
154
|
+
// fan-out over ids that no longer exist — "deterministic and idempotent"
|
|
155
|
+
// means the orchestration dir always mirrors the CURRENT run state.
|
|
156
|
+
const emitted = new Set(opts.eco ? [] : selected.filter((p) => p.items > 0).map((p) => p.name));
|
|
157
|
+
for (const ph of phases) {
|
|
158
|
+
if (emitted.has(ph.name)) continue;
|
|
159
|
+
const stale = join(orchDir, `${ph.name}.workflow.mjs`);
|
|
160
|
+
if (existsSync(stale)) {
|
|
161
|
+
rmSync(stale, { force: true });
|
|
162
|
+
notices.push(`phase "${ph.name}": stale workflow removed — its worklist ${ph.ready ? "is now empty" : "no longer exists"}.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!opts.eco) {
|
|
167
|
+
for (const ph of selected) {
|
|
168
|
+
if (ph.items === 0) {
|
|
169
|
+
notices.push(`phase "${ph.name}": worklist is empty — nothing to orchestrate.`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ph.items <= SMALL_WORKLIST) {
|
|
173
|
+
notices.push(`phase "${ph.name}": only ${ph.items} item(s) — the sequential --eco path is equivalent and cheaper.`);
|
|
174
|
+
}
|
|
175
|
+
const p = join(orchDir, `${ph.name}.workflow.mjs`);
|
|
176
|
+
writeFileSync(p, phaseWorkflowScript(ph, run, engineAbs, BATCH_SIZE));
|
|
177
|
+
written.push(p);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rb = join(orchDir, "RUNBOOK.md");
|
|
182
|
+
writeFileSync(rb, runbookMd(phases, run, engineAbs));
|
|
183
|
+
written.push(rb);
|
|
184
|
+
|
|
185
|
+
return { exitCode: 0, written, notices, errors: [], phases };
|
|
186
|
+
}
|