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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.opencode/plugins/ultra11y.js +1 -1
  5. package/README.md +14 -2
  6. package/package.json +1 -1
  7. package/scripts/ultra11y.mjs +1133 -222
  8. package/skills/review-a11y/SKILL.md +1 -1
  9. package/skills/review-a11y/scripts/ultra11y.mjs +1133 -222
  10. package/skills/ultra11y/SKILL.md +17 -11
  11. package/skills/ultra11y/references/audit.md +1 -1
  12. package/skills/ultra11y/references/ci.md +63 -0
  13. package/skills/ultra11y/references/judgment.md +8 -0
  14. package/skills/ultra11y/references/methodology.md +1 -1
  15. package/skills/ultra11y/references/orchestrators.md +26 -11
  16. package/skills/ultra11y/references/prd.md +9 -36
  17. package/skills/ultra11y/references/tickets.md +156 -0
  18. package/skills/ultra11y/scripts/ultra11y.mjs +1133 -222
  19. package/src/cli.ts +386 -63
  20. package/src/config.ts +10 -0
  21. package/src/gh-cli.ts +39 -0
  22. package/src/mcp/handlers.ts +28 -0
  23. package/src/mcp/tools.ts +27 -0
  24. package/src/pages.ts +13 -0
  25. package/src/pr-comment.ts +78 -0
  26. package/src/tickets/grain.ts +218 -0
  27. package/src/tickets/http.ts +107 -0
  28. package/src/tickets/providers/github.ts +155 -0
  29. package/src/tickets/providers/gitlab.ts +158 -0
  30. package/src/tickets/providers/jira.ts +152 -0
  31. package/src/tickets/providers/remote.ts +40 -0
  32. package/src/tickets/push.ts +94 -0
  33. package/src/tickets/registry.ts +45 -0
  34. package/src/tickets/render.ts +157 -0
  35. package/src/tickets/types.ts +154 -0
  36. package/src/types.ts +1 -1
  37. 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 { ghAvailable, issueSet, pushIssues, pushPrComment, pushSingleIssue, type IssueFormat } from "./gh.js";
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>] [--gh-issues | --gh-single] [--lang auto|en|fr]
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
- --gh-issues files one de-duplicated GitHub issue per criterion, or
127
- --gh-single files the whole audit as a single issue (gh CLI).
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
- --gh-issues prd: also create one GitHub issue per criterion via the gh CLI (opt-in)
296
- --gh-single prd: file the whole audit as ONE consolidated GitHub issue (opt-in; wins over --gh-issues)
297
- --issues-json prd: write the issue set (one item per criterion: title, body, labels,
298
- severity, occurrences) to <out>/issues-<date>.json for a non-GitHub
299
- tracker to file — same items --gh-issues would create, no gh needed
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
- // Tracker-agnostic export: the same de-duplicated items `--gh-issues` files, as JSON, so a
1565
- // board that is not GitHub can file them itself. Written to a file (not stdout) so it
1566
- // composes with --json, and so an orchestrator reads one stable path instead of parsing
1567
- // a payload that also carries the markdown paths.
1568
- let issuesPath: string | undefined;
1569
- if (p.flags["issues-json"] === true) {
1570
- const issueFormat: IssueFormat = format === "remediation" ? "remediation" : "audit";
1571
- const issues = issueSet(prdUnits(result, standard, lang), lang, standard, issueFormat);
1572
- mkdirSync(out, { recursive: true });
1573
- issuesPath = join(out, `issues-${result.date}.json`);
1574
- writeFileSync(
1575
- issuesPath,
1576
- `${JSON.stringify({ tool: "ultra11y", kind: "issues", schemaVersion: 1, standard, date: result.date, count: issues.length, issues }, null, 2)}\n`,
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
- if (!json) console.log(issuesPath);
1579
- }
1580
-
1581
- // GitHub: always-written markdown above; issues are opt-in + best-effort.
1582
- // --gh-single → one consolidated issue; --gh-issues → one issue per criterion.
1583
- const ghMode: "single" | "per-criterion" | null = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
1584
- let gh: { created: number; skipped: number; failed: number; errors: string[] } | undefined;
1585
- if (ghMode) {
1586
- const flag = ghMode === "single" ? "--gh-single" : "--gh-issues";
1587
- const units = prdUnits(result, standard, lang);
1588
- if (!ghAvailable()) {
1589
- if (!json)
1590
- console.error(`ultra11y prd: ${flag} skipped — \`gh\` is not installed or not authenticated (run \`gh auth login\`). Markdown was still written.`);
1591
- } else if (units.length === 0) {
1592
- if (!json) console.error(`ultra11y prd: ${flag} skipped — no findings to file.`);
1593
- } else {
1594
- const issueFormat = format === "remediation" ? "remediation" : "audit";
1595
- gh = ghMode === "single" ? pushSingleIssue(units, lang, standard, issueFormat) : pushIssues(units, lang, standard, issueFormat);
1596
- if (!json)
1597
- console.log(
1598
- lang === "fr"
1599
- ? `ultra11y prd : issues GitHub — ${gh.created} créée(s), ${gh.skipped} déjà existante(s)${gh.failed ? `, ${gh.failed} en échec` : ""}.`
1600
- : `ultra11y prd: GitHub issues — ${gh.created} created, ${gh.skipped} already existed${gh.failed ? `, ${gh.failed} failed` : ""}.`,
1601
- );
1602
- // Surface WHY gh failed (its stderr, previously swallowed) — always, even in --json
1603
- // mode the reasons ride along in the payload; here we print them for humans.
1604
- if (gh.failed && !json) {
1605
- if (gh.errors.length) for (const e of gh.errors) console.error(lang === "fr" ? `ultra11y prd : gh a échoué — ${e}` : `ultra11y prd: gh failed — ${e}`);
1606
- else console.error(lang === "fr" ? `ultra11y prd : gh a échoué sans message d'erreur.` : `ultra11y prd: gh failed with no error output.`);
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
- if (json)
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
+ }
@@ -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));