ultra11y 2.32.0 → 3.0.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +14 -2
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +1133 -222
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +1133 -222
- package/skills/ultra11y/SKILL.md +17 -11
- package/skills/ultra11y/references/audit.md +1 -1
- package/skills/ultra11y/references/ci.md +63 -0
- package/skills/ultra11y/references/judgment.md +8 -0
- package/skills/ultra11y/references/methodology.md +1 -1
- package/skills/ultra11y/references/orchestrators.md +26 -11
- package/skills/ultra11y/references/prd.md +9 -36
- package/skills/ultra11y/references/tickets.md +156 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +1133 -222
- package/src/cli.ts +386 -63
- package/src/config.ts +10 -0
- package/src/gh-cli.ts +39 -0
- package/src/mcp/handlers.ts +28 -0
- package/src/mcp/tools.ts +27 -0
- package/src/pages.ts +13 -0
- package/src/pr-comment.ts +78 -0
- package/src/tickets/grain.ts +218 -0
- package/src/tickets/http.ts +107 -0
- package/src/tickets/providers/github.ts +155 -0
- package/src/tickets/providers/gitlab.ts +158 -0
- package/src/tickets/providers/jira.ts +152 -0
- package/src/tickets/providers/remote.ts +40 -0
- package/src/tickets/push.ts +94 -0
- package/src/tickets/registry.ts +45 -0
- package/src/tickets/render.ts +157 -0
- package/src/tickets/types.ts +154 -0
- package/src/types.ts +1 -1
- package/src/gh.ts +0 -333
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,11 @@ import { runAudit } from "./audit.js";
|
|
|
6
6
|
import { decide, type PreToolUsePayload } from "./hook.js";
|
|
7
7
|
import { writeReport, untestedNeedsRendering, partialAuditBanner } from "./report.js";
|
|
8
8
|
import { writePrd, prdUnits, type PrdFormat } from "./prd.js";
|
|
9
|
-
import {
|
|
9
|
+
import { pushPrComment } from "./pr-comment.js";
|
|
10
|
+
import { buildTickets } from "./tickets/grain.js";
|
|
11
|
+
import { pushTickets } from "./tickets/push.js";
|
|
12
|
+
import { autoProvider, createProvider, isProviderId } from "./tickets/registry.js";
|
|
13
|
+
import { ALL_GRAINS, ALL_PROVIDERS, TICKET_SET_SCHEMA_VERSION, type TicketGrain, type TicketSetFile, type TransportMode } from "./tickets/types.js";
|
|
10
14
|
import {
|
|
11
15
|
detectFrameworks,
|
|
12
16
|
renderPlan,
|
|
@@ -46,7 +50,7 @@ import { crawlUrls, extractTitle, parseSitemapUrls } from "./crawl.js";
|
|
|
46
50
|
import { DEV_DEFAULT_PORT, nextOverlayComponent, startDevServer, type DevServer } from "./dev.js";
|
|
47
51
|
import { cypressCommands, cypressPlugin, detectE2eRunner, e2eSetupPlan, playwrightFixture, type E2ePaths, type E2eRunner } from "./e2e.js";
|
|
48
52
|
import { resolveStandard, getPack, isCore, CORE, type StandardId } from "./standards/index.js";
|
|
49
|
-
import { loadRuntimeStandards, loadConfig } from "./config.js";
|
|
53
|
+
import { loadRuntimeStandards, loadConfig, type Ultra11yConfig } from "./config.js";
|
|
50
54
|
import { runPackCheck, packScaffold } from "./pack.js";
|
|
51
55
|
import { listPhases, orchestrateRun, PHASES } from "./orchestrate.js";
|
|
52
56
|
import { readStdin, readText } from "./util.js";
|
|
@@ -65,8 +69,11 @@ Usage:
|
|
|
65
69
|
ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
|
|
66
70
|
ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] (rendered-DOM captures + .ultra11y/pages snapshots: audit real HTML)
|
|
67
71
|
ultra11y audit [--format sarif|github] (CI: SARIF for code scanning, or inline annotations + job summary)
|
|
72
|
+
ultra11y audit --in <audit.json> [--fail-on blocking|major|minor] [--format sarif|github] (re-gate an audit already computed — e.g. one carrying adjudicated verdicts — without a second detection pass)
|
|
68
73
|
ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--format sarif|github] [--lang auto|en|fr]
|
|
69
|
-
ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--
|
|
74
|
+
ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--lang auto|en|fr]
|
|
75
|
+
ultra11y tickets --in <audit.json> [--provider auto|github|gitlab|jira] [--grain criterion|page|page-criterion|single|file] [--transport auto|cli|rest]
|
|
76
|
+
ultra11y tickets [--out <dir>] [--max-tickets <n>] [--dry-run] [--json] [--standard <pack>] [--format audit|remediation] [--lang auto|en|fr]
|
|
70
77
|
ultra11y render [<dir>] [--scaffold | --setup | --e2e | --coverage | --storybook] [--runner playwright|cypress|auto] [--captures <dir>] [--out <file>] [--json] [--lang auto|en|fr]
|
|
71
78
|
ultra11y criteria [<sc>] [--list] [--standard <pack> [--theme <N>]] [--generate] [--json] [--lang auto|en|fr]
|
|
72
79
|
ultra11y criteria --standard <pack> --glossary [<term>] (the terms the standard DEFINES — its tests depend on them)
|
|
@@ -122,9 +129,14 @@ Commands:
|
|
|
122
129
|
active standard's vocabulary (RGAA "Thématique/Critère/Test", WCAG core
|
|
123
130
|
"Principle·Guideline/Success criterion/Technique") — theme, criterion +
|
|
124
131
|
official wording, test(s), WCAG mapping + level, finding, expected state,
|
|
125
|
-
verification. --split criterion writes one file per criterion
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
verification. --split criterion writes one file per criterion.
|
|
133
|
+
It writes MARKDOWN only — filing tickets is the 'tickets' command.
|
|
134
|
+
tickets File the audit as TRACKER TICKETS — GitHub, GitLab or Jira. Reads an
|
|
135
|
+
audit.json and pushes; it writes no markdown, exactly as prd/report
|
|
136
|
+
write markdown and push nothing. --grain chooses what ONE ticket is:
|
|
137
|
+
per criterion (default), per page, per page+criterion, per file, or one
|
|
138
|
+
consolidated. De-dupe is by exact title, so re-running never duplicates.
|
|
139
|
+
--dry-run prints the plan without creating anything.
|
|
128
140
|
render Get RENDERED HTML to audit (so component libraries like DSFR are
|
|
129
141
|
checked as the HTML they emit, not their JSX sources): detect the
|
|
130
142
|
framework and print the build→audit recipe, or --scaffold a
|
|
@@ -292,11 +304,14 @@ Options:
|
|
|
292
304
|
plus <out>/index.md (recommended past 3–4 pages: RGAA is 106 criteria)
|
|
293
305
|
--no-technical prd (audit format): omit the technical ticket sections (Partie
|
|
294
306
|
technique + Contexte de reproduction) for a pure-auditor block
|
|
295
|
-
--
|
|
296
|
-
|
|
297
|
-
--
|
|
298
|
-
|
|
299
|
-
|
|
307
|
+
--provider <id> tickets: auto|github|gitlab|jira. 'auto' reads ULTRA11Y_TICKET_PROVIDER,
|
|
308
|
+
then .ultra11yrc.json, then the git remote (Jira is never auto-detected)
|
|
309
|
+
--grain <mode> tickets: what one ticket is — criterion (default) | page | page-criterion | single | file
|
|
310
|
+
--transport <t> tickets: auto|cli|rest (mcp: stdio|http). 'auto' prefers the CLI (gh/glab),
|
|
311
|
+
falling back to REST when only a token is available. Jira is REST-only
|
|
312
|
+
--max-tickets <n> tickets: refuse to file more than n tickets in one run (default 200)
|
|
313
|
+
tickets --out <dir> also writes the tracker-agnostic set to
|
|
314
|
+
<dir>/issues-<date>.json, for a workflow engine to file itself
|
|
300
315
|
--scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
|
|
301
316
|
--setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
|
|
302
317
|
--coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
|
|
@@ -406,6 +421,7 @@ export const COMMANDS = [
|
|
|
406
421
|
"audit",
|
|
407
422
|
"report",
|
|
408
423
|
"prd",
|
|
424
|
+
"tickets",
|
|
409
425
|
"render",
|
|
410
426
|
"criteria",
|
|
411
427
|
"check",
|
|
@@ -434,6 +450,9 @@ function isCommand(s: string | undefined): s is Command {
|
|
|
434
450
|
|
|
435
451
|
const VALUE_FLAGS = new Set([
|
|
436
452
|
"out",
|
|
453
|
+
"provider",
|
|
454
|
+
"grain",
|
|
455
|
+
"max-tickets",
|
|
437
456
|
"in",
|
|
438
457
|
"include",
|
|
439
458
|
"exclude",
|
|
@@ -528,9 +547,6 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
528
547
|
"semantic",
|
|
529
548
|
"manual",
|
|
530
549
|
"no-technical",
|
|
531
|
-
"gh-issues",
|
|
532
|
-
"gh-single",
|
|
533
|
-
"issues-json",
|
|
534
550
|
"override",
|
|
535
551
|
"local",
|
|
536
552
|
"docker",
|
|
@@ -736,7 +752,90 @@ function emitCiFormat(result: AuditResult, format: CiFormat, standard: StandardI
|
|
|
736
752
|
}
|
|
737
753
|
}
|
|
738
754
|
|
|
755
|
+
// `audit --in <audit.json>` — RE-GATE an audit that was already computed, instead of
|
|
756
|
+
// computing one. It exists for one caller: a pipeline that folded ADJUDICATED verdicts into
|
|
757
|
+
// audit-latest.json and now wants the same severity gate applied to that result. Re-running
|
|
758
|
+
// detection there would be wrong twice over — it would not see the verdicts, and it would
|
|
759
|
+
// spend a second full pass to reach a conclusion the file already holds.
|
|
760
|
+
//
|
|
761
|
+
// Deliberately narrow: this path GATES and RENDERS, nothing else. Every flag that would
|
|
762
|
+
// change what is audited is refused rather than ignored, because a gate that silently drops
|
|
763
|
+
// your scoping is a gate nobody can trust. Re-running detection is what `audit <globs>` is
|
|
764
|
+
// for.
|
|
765
|
+
async function cmdAuditFromFile(p: ParsedArgs, inPath: string): Promise<number> {
|
|
766
|
+
const scoping = [
|
|
767
|
+
"since",
|
|
768
|
+
"changed",
|
|
769
|
+
"staged",
|
|
770
|
+
"baseline",
|
|
771
|
+
"graph",
|
|
772
|
+
"cross-file",
|
|
773
|
+
"jsx",
|
|
774
|
+
"include",
|
|
775
|
+
"exclude",
|
|
776
|
+
"ext",
|
|
777
|
+
"max-files",
|
|
778
|
+
"dedup",
|
|
779
|
+
"captures",
|
|
780
|
+
"no-captures",
|
|
781
|
+
"require-captures",
|
|
782
|
+
"no-default-excludes",
|
|
783
|
+
"out",
|
|
784
|
+
];
|
|
785
|
+
const offenders = scoping.filter((f) => p.flags[f] !== undefined);
|
|
786
|
+
if (p.positionals.length) offenders.unshift("<paths>");
|
|
787
|
+
if (offenders.length) {
|
|
788
|
+
console.error(
|
|
789
|
+
`ultra11y audit: --in re-gates an audit that already exists, so these have nothing left to change: ${offenders.join(", ")}. Drop them, or run a fresh audit without --in.`,
|
|
790
|
+
);
|
|
791
|
+
return 2;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
let result: AuditResult;
|
|
795
|
+
try {
|
|
796
|
+
const parsed = JSON.parse(inPath === "-" ? await readStdin() : readText(inPath)) as unknown;
|
|
797
|
+
if (!isCurrentAudit(parsed)) {
|
|
798
|
+
console.error("ultra11y audit: --in is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
|
|
799
|
+
return 2;
|
|
800
|
+
}
|
|
801
|
+
result = parsed;
|
|
802
|
+
} catch {
|
|
803
|
+
console.error(`ultra11y audit: --in file not found or not valid JSON: ${inPath}.`);
|
|
804
|
+
return 2;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const lang = resolveLang(p.flags, { audit: result });
|
|
808
|
+
|
|
809
|
+
const failOnRaw = p.flags["fail-on"];
|
|
810
|
+
const failOnParsed = parseFailOn(failOnRaw);
|
|
811
|
+
if (failOnRaw !== undefined && failOnParsed === null) {
|
|
812
|
+
console.error(`ultra11y audit: --fail-on must be blocking|major|minor (got "${String(failOnRaw)}").`);
|
|
813
|
+
return 2;
|
|
814
|
+
}
|
|
815
|
+
const ciFormat = parseCiFormat(p.flags.format);
|
|
816
|
+
if (ciFormat === null) {
|
|
817
|
+
console.error(`ultra11y audit: --format must be sarif|github (got "${String(p.flags.format)}").`);
|
|
818
|
+
return 2;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const failOnSet = failOnRaw !== undefined;
|
|
822
|
+
const failOn = failOnSet ? (failOnParsed ?? "bloquant") : undefined;
|
|
823
|
+
const failing = failOn ? findingsAtOrAbove(result.findings, failOn) : [];
|
|
824
|
+
|
|
825
|
+
if (ciFormat) emitCiFormat(result, ciFormat, CORE, lang, failOn);
|
|
826
|
+
else if (p.flags.json) console.log(JSON.stringify(result, null, 2));
|
|
827
|
+
else {
|
|
828
|
+
console.log(auditSummary(result, lang));
|
|
829
|
+
if (failOnSet && failing.length)
|
|
830
|
+
console.error(lang === "fr" ? `✗ ${failing.length} non-conformité(s) ≥ ${failOn}.` : `✗ ${failing.length} non-conformity(ies) ≥ ${failOn}.`);
|
|
831
|
+
}
|
|
832
|
+
return failing.length ? 1 : 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
739
835
|
async function cmdAudit(p: ParsedArgs): Promise<number> {
|
|
836
|
+
const inFlag = p.flags.in;
|
|
837
|
+
if (typeof inFlag === "string" && inFlag) return cmdAuditFromFile(p, inFlag);
|
|
838
|
+
|
|
740
839
|
const inputs = p.positionals.length ? p.positionals : ["."];
|
|
741
840
|
if (inputs.length === 0) {
|
|
742
841
|
console.error("ultra11y audit: provide files/globs, or '-' to read stdin.");
|
|
@@ -1561,58 +1660,265 @@ async function cmdPrd(p: ParsedArgs): Promise<number> {
|
|
|
1561
1660
|
const json = p.flags.json === true;
|
|
1562
1661
|
if (!json) for (const path of paths) console.log(path);
|
|
1563
1662
|
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
// a
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1663
|
+
// `prd` writes MARKDOWN. It files nothing: ticket creation is `ultra11y tickets`, which
|
|
1664
|
+
// reads the same audit.json and writes no markdown. That split is deliberate — you could
|
|
1665
|
+
// not previously push without also producing a document, nor produce a document without
|
|
1666
|
+
// risking a push.
|
|
1667
|
+
if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang) }, null, 2));
|
|
1668
|
+
return 0;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** Flags that USED to exist, mapped to what replaces them. Checked before the generic
|
|
1672
|
+
* unknown-flag warning so a removal is loud on the first run, not discovered later from an
|
|
1673
|
+
* empty tracker. Reusable for the next removal — one table, one place. */
|
|
1674
|
+
export const REMOVED_FLAGS: Record<string, string> = {
|
|
1675
|
+
"gh-issues": "ultra11y tickets --in <audit.json> --provider github --grain criterion",
|
|
1676
|
+
"gh-single": "ultra11y tickets --in <audit.json> --provider github --grain single",
|
|
1677
|
+
// The tracker-agnostic export moved WITH ticket filing: `tickets --out` writes the same
|
|
1678
|
+
// envelope (and a superset of the payload) at any grain, so an orchestrator still reads
|
|
1679
|
+
// one stable path — just not from the command that renders documents.
|
|
1680
|
+
"issues-json": "ultra11y tickets --in <audit.json> --out <dir> --grain criterion",
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
/** Config keys that must NEVER appear in `.ultra11yrc.json` — a committed credential is a
|
|
1684
|
+
* leaked credential. Named explicitly rather than pattern-matched, so the error can say what
|
|
1685
|
+
* to do instead. */
|
|
1686
|
+
const SECRET_CONFIG_KEYS = ["token", "apiToken", "api_token", "password", "secret"];
|
|
1687
|
+
|
|
1688
|
+
async function cmdTickets(p: ParsedArgs): Promise<number> {
|
|
1689
|
+
const standard = stdOf(p, "tickets");
|
|
1690
|
+
if (standard === null) return 2;
|
|
1691
|
+
const inFlag = p.flags.in;
|
|
1692
|
+
if (typeof inFlag !== "string" || !inFlag) {
|
|
1693
|
+
console.error("ultra11y tickets: --in <audit.json> is required ('-' for stdin). Run `audit --out` first.");
|
|
1694
|
+
return 2;
|
|
1695
|
+
}
|
|
1696
|
+
const raw = inFlag === "-" ? await readStdin() : readInputFile(inFlag, "tickets", "--in");
|
|
1697
|
+
if (raw === null) return 2;
|
|
1698
|
+
let result: unknown;
|
|
1699
|
+
try {
|
|
1700
|
+
result = JSON.parse(raw);
|
|
1701
|
+
} catch {
|
|
1702
|
+
console.error("ultra11y tickets: --in is not valid JSON (expected an AuditResult).");
|
|
1703
|
+
return 2;
|
|
1704
|
+
}
|
|
1705
|
+
if (!isCurrentAudit(result)) {
|
|
1706
|
+
console.error("ultra11y tickets: input is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
|
|
1707
|
+
return 2;
|
|
1708
|
+
}
|
|
1709
|
+
const lang = resolveLang(p.flags, { audit: result, standard });
|
|
1710
|
+
const fr = lang === "fr";
|
|
1711
|
+
const json = p.flags.json === true;
|
|
1712
|
+
const dryRun = p.flags["dry-run"] === true;
|
|
1713
|
+
|
|
1714
|
+
let config: Ultra11yConfig["tickets"];
|
|
1715
|
+
try {
|
|
1716
|
+
config = loadConfig(process.cwd())?.tickets;
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
console.error(`ultra11y tickets: ${e instanceof Error ? e.message : String(e)}`);
|
|
1719
|
+
return 2;
|
|
1720
|
+
}
|
|
1721
|
+
for (const key of SECRET_CONFIG_KEYS) {
|
|
1722
|
+
if (config && key in (config as Record<string, unknown>)) {
|
|
1723
|
+
console.error(
|
|
1724
|
+
`ultra11y tickets: .ultra11yrc.json must not carry a "${key}" — credentials belong in the environment (see references/tickets.md). Remove it and rotate that value.`,
|
|
1725
|
+
);
|
|
1726
|
+
return 2;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// --- usage, before any I/O --------------------------------------------------------------
|
|
1731
|
+
// Everything decidable from the argv alone is decided HERE, ahead of provider resolution.
|
|
1732
|
+
// Resolving a provider spawns `git config --get remote.origin.url`, and an `auto`
|
|
1733
|
+
// transport probes `gh`/`glab auth status` — which reaches the network. A mistyped
|
|
1734
|
+
// `--grain` should never cost a subprocess, let alone a network round-trip, and the error
|
|
1735
|
+
// the caller gets should name what they typed rather than a tracker they never mentioned.
|
|
1736
|
+
//
|
|
1737
|
+
// It was also a real flake: `tickets --grain nope` spent its whole 5s test budget in those
|
|
1738
|
+
// probes on a loaded CI runner, and the release gate went red on a purely syntactic error.
|
|
1739
|
+
const grainFlag = typeof p.flags.grain === "string" ? (p.flags.grain as string) : (config?.grain ?? "criterion");
|
|
1740
|
+
if (!(ALL_GRAINS as readonly string[]).includes(grainFlag)) {
|
|
1741
|
+
console.error(`ultra11y tickets: --grain must be one of ${ALL_GRAINS.join("|")} (got "${grainFlag}").`);
|
|
1742
|
+
return 2;
|
|
1743
|
+
}
|
|
1744
|
+
const transportFlag = typeof p.flags.transport === "string" ? (p.flags.transport as string) : (config?.transport ?? "auto");
|
|
1745
|
+
if (!["auto", "cli", "rest"].includes(transportFlag)) {
|
|
1746
|
+
console.error(`ultra11y tickets: --transport must be auto, cli or rest (got "${transportFlag}").`);
|
|
1747
|
+
return 2;
|
|
1748
|
+
}
|
|
1749
|
+
const maxTickets = Number.parseInt(String(p.flags["max-tickets"] ?? config?.maxTickets ?? 200), 10);
|
|
1750
|
+
if (!Number.isFinite(maxTickets) || maxTickets < 1) {
|
|
1751
|
+
console.error("ultra11y tickets: --max-tickets must be a positive integer.");
|
|
1752
|
+
return 2;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// --- provider ---------------------------------------------------------------------------
|
|
1756
|
+
const providerFlag = typeof p.flags.provider === "string" ? (p.flags.provider as string) : "auto";
|
|
1757
|
+
const providerId = providerFlag === "auto" ? autoProvider(process.env, config?.provider) : isProviderId(providerFlag) ? providerFlag : undefined;
|
|
1758
|
+
if (!providerId) {
|
|
1759
|
+
console.error(
|
|
1760
|
+
providerFlag === "auto"
|
|
1761
|
+
? "ultra11y tickets: could not tell which tracker to file into — pass --provider github|gitlab|jira (Jira is never auto-detected: it owns no git remote)."
|
|
1762
|
+
: `ultra11y tickets: --provider "${providerFlag}" is not one of ${ALL_PROVIDERS.join("|")}.`,
|
|
1577
1763
|
);
|
|
1578
|
-
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1764
|
+
return 2;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const provider = createProvider(providerId, { transport: transportFlag as TransportMode });
|
|
1768
|
+
|
|
1769
|
+
// --- the plan ---------------------------------------------------------------------------
|
|
1770
|
+
const format = p.flags.format === "remediation" ? "remediation" : "audit";
|
|
1771
|
+
const plan = buildTickets(result, {
|
|
1772
|
+
grain: grainFlag as TicketGrain,
|
|
1773
|
+
standard,
|
|
1774
|
+
lang,
|
|
1775
|
+
format,
|
|
1776
|
+
bodyLimit: provider.capabilities.bodyLimit,
|
|
1777
|
+
baseDir: process.cwd(),
|
|
1778
|
+
technical: p.flags["no-technical"] !== true,
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
if (plan.error === "no-pages") {
|
|
1782
|
+
console.error(
|
|
1783
|
+
fr
|
|
1784
|
+
? "ultra11y tickets : aucune page dans le périmètre. Capturez des instantanés (render --e2e) ou scannez un échantillon (scan --sample) avant d'utiliser --grain page."
|
|
1785
|
+
: "ultra11y tickets: no page in scope. Capture snapshots (render --e2e) or scan a sample (scan --sample) before using --grain page.",
|
|
1786
|
+
);
|
|
1787
|
+
return 1;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// A creation is hard to undo, and `page-criterion` on a large audit runs to the hundreds.
|
|
1791
|
+
// Refuse rather than flood somebody's tracker; never silently truncate.
|
|
1792
|
+
if (plan.tickets.length > maxTickets) {
|
|
1793
|
+
console.error(
|
|
1794
|
+
fr
|
|
1795
|
+
? `ultra11y tickets : ${plan.tickets.length} tickets pour --grain ${grainFlag}, au-delà de la limite de ${maxTickets}. Relancez avec --max-tickets ${plan.tickets.length}, un grain plus grossier, ou --dry-run pour inspecter.`
|
|
1796
|
+
: `ultra11y tickets: ${plan.tickets.length} tickets for --grain ${grainFlag}, past the limit of ${maxTickets}. Re-run with --max-tickets ${plan.tickets.length}, a coarser grain, or --dry-run to inspect.`,
|
|
1797
|
+
);
|
|
1798
|
+
return 2;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// `--out` writes the tracker-agnostic SET to a stable path, for a workflow engine that
|
|
1802
|
+
// files the items itself. It is not a document — it is the ticket payload — which is why
|
|
1803
|
+
// it lives here rather than on `prd`. Writing it never files anything.
|
|
1804
|
+
const ticketsOut = typeof p.flags.out === "string" && p.flags.out ? (p.flags.out as string) : undefined;
|
|
1805
|
+
let setPath: string | undefined;
|
|
1806
|
+
if (ticketsOut) {
|
|
1807
|
+
mkdirSync(ticketsOut, { recursive: true });
|
|
1808
|
+
setPath = join(ticketsOut, `issues-${result.date}.json`);
|
|
1809
|
+
const payload: TicketSetFile = {
|
|
1810
|
+
tool: "ultra11y",
|
|
1811
|
+
kind: "issues",
|
|
1812
|
+
schemaVersion: TICKET_SET_SCHEMA_VERSION,
|
|
1813
|
+
standard,
|
|
1814
|
+
grain: grainFlag as TicketGrain,
|
|
1815
|
+
date: result.date,
|
|
1816
|
+
count: plan.tickets.length,
|
|
1817
|
+
issues: plan.tickets,
|
|
1818
|
+
};
|
|
1819
|
+
writeFileSync(setPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
1820
|
+
if (!json) console.log(setPath);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (!plan.tickets.length) {
|
|
1824
|
+
if (!json)
|
|
1825
|
+
console.log(
|
|
1826
|
+
fr
|
|
1827
|
+
? "ultra11y tickets : rien à déposer — l'audit ne relève aucune non-conformité."
|
|
1828
|
+
: "ultra11y tickets: nothing to file — the audit found no non-conformity.",
|
|
1829
|
+
);
|
|
1830
|
+
if (json)
|
|
1831
|
+
console.log(
|
|
1832
|
+
JSON.stringify(
|
|
1833
|
+
{
|
|
1834
|
+
provider: providerId,
|
|
1835
|
+
transport: provider.transport,
|
|
1836
|
+
grain: grainFlag,
|
|
1837
|
+
standard,
|
|
1838
|
+
dryRun,
|
|
1839
|
+
...(setPath ? { setPath } : {}),
|
|
1840
|
+
tickets: [],
|
|
1841
|
+
unattributed: plan.unattributed,
|
|
1842
|
+
result: { created: 0, skipped: 0, failed: 0, createdTitles: [], createdUrls: [], errors: [] },
|
|
1843
|
+
},
|
|
1844
|
+
null,
|
|
1845
|
+
2,
|
|
1846
|
+
),
|
|
1847
|
+
);
|
|
1848
|
+
return 0;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// A push command that files nothing and reports green is a silent failure — unlike the old
|
|
1852
|
+
// `prd --gh-issues`, whose push was an optional extra on top of a document.
|
|
1853
|
+
if (!provider.available() && !dryRun) {
|
|
1854
|
+
console.error(`ultra11y tickets: ${providerId} is not usable here — ${provider.unavailableReason()}.`);
|
|
1855
|
+
return 1;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const { plan: planned, result: pushed, dedupeChecked } = await pushTickets(plan.tickets, provider, { dryRun: dryRun || !provider.available() });
|
|
1859
|
+
|
|
1860
|
+
if (json) {
|
|
1861
|
+
console.log(
|
|
1862
|
+
JSON.stringify(
|
|
1863
|
+
{
|
|
1864
|
+
provider: providerId,
|
|
1865
|
+
transport: provider.transport,
|
|
1866
|
+
grain: grainFlag,
|
|
1867
|
+
standard,
|
|
1868
|
+
dryRun,
|
|
1869
|
+
...(setPath ? { setPath } : {}),
|
|
1870
|
+
dedupeChecked,
|
|
1871
|
+
tickets: planned.map(({ ticket, action }) => ({
|
|
1872
|
+
title: ticket.title,
|
|
1873
|
+
labels: ticket.labels,
|
|
1874
|
+
severity: ticket.severity,
|
|
1875
|
+
advisory: ticket.advisory,
|
|
1876
|
+
scope: ticket.scope,
|
|
1877
|
+
bodyChars: ticket.body.length,
|
|
1878
|
+
action,
|
|
1879
|
+
// The body rides along ONLY under --dry-run: an agent inspecting the plan wants
|
|
1880
|
+
// it, a 200-ticket push payload does not.
|
|
1881
|
+
...(dryRun ? { body: ticket.body } : {}),
|
|
1882
|
+
})),
|
|
1883
|
+
unattributed: plan.unattributed,
|
|
1884
|
+
result: pushed,
|
|
1885
|
+
},
|
|
1886
|
+
null,
|
|
1887
|
+
2,
|
|
1888
|
+
),
|
|
1889
|
+
);
|
|
1890
|
+
} else if (dryRun || !provider.available()) {
|
|
1891
|
+
const create = planned.filter((x) => x.action === "create").length;
|
|
1892
|
+
console.log(
|
|
1893
|
+
fr
|
|
1894
|
+
? `ultra11y tickets : simulation (${providerId}/${provider.transport}, grain ${grainFlag}) — ${create} à créer, ${pushed.skipped} déjà présent(s).`
|
|
1895
|
+
: `ultra11y tickets: dry run (${providerId}/${provider.transport}, grain ${grainFlag}) — ${create} to create, ${pushed.skipped} already there.`,
|
|
1896
|
+
);
|
|
1897
|
+
for (const { ticket, action } of planned) console.log(` ${action === "create" ? "+" : "="} ${ticket.title}`);
|
|
1898
|
+
if (!dedupeChecked)
|
|
1899
|
+
console.error(
|
|
1900
|
+
fr
|
|
1901
|
+
? "ultra11y tickets : l'existant n'a pas pu être listé — le compte « déjà présent » n'est pas vérifié."
|
|
1902
|
+
: 'ultra11y tickets: could not list existing tickets — the "already there" count is unverified.',
|
|
1903
|
+
);
|
|
1904
|
+
if (!provider.available()) console.error(`ultra11y tickets: ${providerId} — ${provider.unavailableReason()}.`);
|
|
1905
|
+
} else {
|
|
1906
|
+
console.log(
|
|
1907
|
+
fr
|
|
1908
|
+
? `ultra11y tickets : ${providerId} — ${pushed.created} créé(s), ${pushed.skipped} déjà présent(s)${pushed.failed ? `, ${pushed.failed} en échec` : ""}.`
|
|
1909
|
+
: `ultra11y tickets: ${providerId} — ${pushed.created} created, ${pushed.skipped} already there${pushed.failed ? `, ${pushed.failed} failed` : ""}.`,
|
|
1910
|
+
);
|
|
1911
|
+
for (const u of pushed.createdUrls) console.log(` ${u}`);
|
|
1912
|
+
for (const e of pushed.errors) console.error(fr ? `ultra11y tickets : échec — ${e}` : `ultra11y tickets: failed — ${e}`);
|
|
1913
|
+
if (plan.unattributed) {
|
|
1914
|
+
console.error(
|
|
1915
|
+
fr
|
|
1916
|
+
? `ultra11y tickets : ${plan.unattributed} constat(s) non rattaché(s) à une page — déposés dans leur propre ticket, jamais répartis d'office.`
|
|
1917
|
+
: `ultra11y tickets: ${plan.unattributed} finding(s) attributed to no page — filed as their own ticket, never spread.`,
|
|
1918
|
+
);
|
|
1608
1919
|
}
|
|
1609
1920
|
}
|
|
1610
|
-
|
|
1611
|
-
console.log(JSON.stringify({ paths, ...(issuesPath ? { issuesPath } : {}), units: prdUnits(result, standard, lang), ...(gh ? { gh } : {}) }, null, 2));
|
|
1612
|
-
// Markdown was written above regardless; but if issue creation was attempted and had
|
|
1613
|
-
// any failures, exit non-zero so a CI step / caller sees the GitHub push did not fully
|
|
1614
|
-
// succeed (a total failure previously exited 0 and looked green).
|
|
1615
|
-
return gh && gh.failed > 0 ? 1 : 0;
|
|
1921
|
+
return pushed.failed > 0 ? 1 : 0;
|
|
1616
1922
|
}
|
|
1617
1923
|
|
|
1618
1924
|
/** Merged dependencies of the package.json at `root` (empty when absent/unparseable — the
|
|
@@ -2867,6 +3173,16 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
2867
3173
|
console.log(HELP);
|
|
2868
3174
|
return 0;
|
|
2869
3175
|
}
|
|
3176
|
+
// A REMOVED flag must not fall through to the generic "unknown flag (ignored)" warning
|
|
3177
|
+
// below: a scripted CI would keep exiting 0 while filing nothing at all — precisely the
|
|
3178
|
+
// silent failure this release exists to end. Name the replacement and fail.
|
|
3179
|
+
for (const f of p.unknown) {
|
|
3180
|
+
const replacement = REMOVED_FLAGS[f];
|
|
3181
|
+
if (replacement) {
|
|
3182
|
+
console.error(`ultra11y: --${f} was removed in v3 — ticket creation is now its own command. Use: ${replacement}`);
|
|
3183
|
+
return 2;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
2870
3186
|
// Warn (never silently ignore) on misspelled/unknown flags so `--grph` or
|
|
2871
3187
|
// `--standrd rgaa` can't quietly leave cross-file/a standard disabled.
|
|
2872
3188
|
for (const f of p.unknown) console.error(`ultra11y: unknown flag --${f} (ignored). Run \`ultra11y --help\`.`);
|
|
@@ -2885,6 +3201,11 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
2885
3201
|
format: ["audit", "doc", "remediation", "sarif", "github", "grid", "report"],
|
|
2886
3202
|
split: ["criterion", "page"],
|
|
2887
3203
|
runtime: ["auto", "local", "docker"],
|
|
3204
|
+
provider: ["auto", "github", "gitlab", "jira"],
|
|
3205
|
+
grain: ["criterion", "page", "page-criterion", "single", "file"],
|
|
3206
|
+
// The union over every command that takes it: `mcp` serves stdio|http, `tickets`
|
|
3207
|
+
// picks cli|rest. Listing one command's values made the guard cry wolf on the other.
|
|
3208
|
+
transport: ["stdio", "http", "auto", "cli", "rest"],
|
|
2888
3209
|
};
|
|
2889
3210
|
for (const [flag, allowed] of Object.entries(ENUM_FLAGS)) {
|
|
2890
3211
|
const v = p.flags[flag];
|
|
@@ -2913,6 +3234,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
2913
3234
|
return cmdAudit(p);
|
|
2914
3235
|
case "report":
|
|
2915
3236
|
return cmdReport(p);
|
|
3237
|
+
case "tickets":
|
|
3238
|
+
return cmdTickets(p);
|
|
2916
3239
|
case "prd":
|
|
2917
3240
|
return cmdPrd(p);
|
|
2918
3241
|
case "render":
|
package/src/config.ts
CHANGED
|
@@ -35,6 +35,16 @@ export interface Ultra11yConfig {
|
|
|
35
35
|
// severity at which a pending commit/push/PR asks the agent for a11y review; "off"
|
|
36
36
|
// disables the hook for this repo. Absent ⇒ "blocking", matching `init --hook`.
|
|
37
37
|
hook?: { failOn?: string };
|
|
38
|
+
// Defaults for `ultra11y tickets` (src/tickets/), so a repo declares its tracker once
|
|
39
|
+
// instead of repeating flags in every CI step. Flags always win, the same precedence
|
|
40
|
+
// `standard` has. CREDENTIALS ARE NOT ALLOWED HERE: this file is committed, so a token
|
|
41
|
+
// key in this block is a hard error — they belong in the environment.
|
|
42
|
+
tickets?: {
|
|
43
|
+
provider?: "github" | "gitlab" | "jira";
|
|
44
|
+
grain?: "criterion" | "page" | "page-criterion" | "single" | "file";
|
|
45
|
+
transport?: "auto" | "cli" | "rest";
|
|
46
|
+
maxTickets?: number;
|
|
47
|
+
};
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
export interface LoadResult {
|
package/src/gh-cli.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The `gh` BINARY ADAPTER — nothing else. Shelling out is how ultra11y talks to GitHub
|
|
2
|
+
// without an npm dependency and without ever holding a token: `gh` handles its own auth.
|
|
3
|
+
//
|
|
4
|
+
// This module imports nothing from ultra11y on purpose, so it can be shared by the two
|
|
5
|
+
// unrelated things that need `gh` — the sticky PR comment (src/pr-comment.ts, a report
|
|
6
|
+
// surface) and the GitHub ticket provider (src/tickets/providers/github.ts) — without
|
|
7
|
+
// creating a cycle or making either depend on the other.
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
/** Run `gh` and return its stdout. Captures stderr (pipe, not ignore) so a failed call
|
|
11
|
+
* carries a surfaceable reason on the thrown error instead of vanishing. */
|
|
12
|
+
export function ghExec(args: string[], input?: string): string {
|
|
13
|
+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...(input !== undefined ? { input } : {}) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extract a concise, single-line failure reason from a thrown `gh` error — the first
|
|
17
|
+
* meaningful stderr line (where `gh` prints the API/auth error), else the error message. */
|
|
18
|
+
export function ghErrorReason(err: unknown): string | undefined {
|
|
19
|
+
if (!err || typeof err !== "object") return undefined;
|
|
20
|
+
const e = err as { stderr?: unknown; message?: unknown };
|
|
21
|
+
const stderr = typeof e.stderr === "string" ? e.stderr : Buffer.isBuffer(e.stderr) ? e.stderr.toString("utf8") : "";
|
|
22
|
+
const line = stderr
|
|
23
|
+
.split("\n")
|
|
24
|
+
.map((l) => l.trim())
|
|
25
|
+
.find(Boolean);
|
|
26
|
+
if (line) return line;
|
|
27
|
+
if (typeof e.message === "string" && e.message) return e.message.split("\n")[0]!.trim() || undefined;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Is the `gh` CLI installed AND authenticated here? */
|
|
32
|
+
export function ghAvailable(): boolean {
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("gh", ["auth", "status"], { stdio: "ignore" });
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/mcp/handlers.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { writeHook, ciWorkflow } from "../init.js";
|
|
|
9
9
|
import { runPackCheck } from "../pack.js";
|
|
10
10
|
import { prdUnits, partitionUnits } from "../prd.js";
|
|
11
11
|
import { renderReport, renderPackReport } from "../report.js";
|
|
12
|
+
import { buildTickets } from "../tickets/grain.js";
|
|
13
|
+
import type { TicketGrain } from "../tickets/types.js";
|
|
12
14
|
import { buildWorklist, formatWorklist } from "../verify.js";
|
|
13
15
|
import { allSC, getSC } from "../wcag.js";
|
|
14
16
|
import { loadPack } from "../standards/index.js";
|
|
@@ -129,6 +131,8 @@ async function dispatch(name: string, args: Record<string, unknown>, cwd: string
|
|
|
129
131
|
return handleReport(args, cwd);
|
|
130
132
|
case "ultra11y_prd":
|
|
131
133
|
return handlePrd(args, cwd);
|
|
134
|
+
case "ultra11y_tickets":
|
|
135
|
+
return handleTickets(args, cwd);
|
|
132
136
|
case "ultra11y_check":
|
|
133
137
|
return handleCheck(args, cwd);
|
|
134
138
|
case "ultra11y_verify":
|
|
@@ -242,6 +246,30 @@ function handlePrd(args: Record<string, unknown>, cwd: string): unknown {
|
|
|
242
246
|
};
|
|
243
247
|
}
|
|
244
248
|
|
|
249
|
+
/** `ultra11y_tickets` — the ticket PLAN, and only the plan. It never files anything.
|
|
250
|
+
* Creating issues in somebody's tracker is an outward-facing, hard-to-undo side effect, and
|
|
251
|
+
* a prompt-injected agent must not be able to trigger it: filing stays a deliberate human
|
|
252
|
+
* `ultra11y tickets` invocation. This tool exists so an agent can SHOW the backlog it would
|
|
253
|
+
* open, then hand the reader the exact command. */
|
|
254
|
+
function handleTickets(args: Record<string, unknown>, cwd: string): unknown {
|
|
255
|
+
const standard = standardOf(args);
|
|
256
|
+
const lang = langOf(args);
|
|
257
|
+
const grain = (str(args.grain) ?? "criterion") as TicketGrain;
|
|
258
|
+
const plan = buildTickets(audit(args, cwd), { grain, standard, lang, baseDir: cwd });
|
|
259
|
+
if (plan.error === "no-pages") {
|
|
260
|
+
return { cwd, standard, grain, error: "no-pages", hint: "No page in scope — capture snapshots (render --e2e) or scan a sample (scan --sample) first." };
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
cwd,
|
|
264
|
+
standard,
|
|
265
|
+
grain,
|
|
266
|
+
dry_run: true,
|
|
267
|
+
tickets: plan.tickets.map((t) => ({ title: t.title, labels: t.labels, severity: t.severity, advisory: t.advisory, scope: t.scope, body: t.body })),
|
|
268
|
+
unattributed: plan.unattributed,
|
|
269
|
+
to_file_them: `ultra11y tickets --in <audit.json> --provider github|gitlab|jira --grain ${grain}`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
245
273
|
function handleCheck(args: Record<string, unknown>, cwd: string): unknown {
|
|
246
274
|
const text = reportText(args, "check");
|
|
247
275
|
const res = checkReport(text, standardOf(args), langOf(args));
|