ultra11y 2.32.0 → 2.32.1

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/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";
@@ -66,7 +70,9 @@ Usage:
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)
68
72
  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]
73
+ ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--lang auto|en|fr]
74
+ ultra11y tickets --in <audit.json> [--provider auto|github|gitlab|jira] [--grain criterion|page|page-criterion|single|file] [--transport auto|cli|rest]
75
+ ultra11y tickets [--out <dir>] [--max-tickets <n>] [--dry-run] [--json] [--standard <pack>] [--format audit|remediation] [--lang auto|en|fr]
70
76
  ultra11y render [<dir>] [--scaffold | --setup | --e2e | --coverage | --storybook] [--runner playwright|cypress|auto] [--captures <dir>] [--out <file>] [--json] [--lang auto|en|fr]
71
77
  ultra11y criteria [<sc>] [--list] [--standard <pack> [--theme <N>]] [--generate] [--json] [--lang auto|en|fr]
72
78
  ultra11y criteria --standard <pack> --glossary [<term>] (the terms the standard DEFINES — its tests depend on them)
@@ -122,9 +128,14 @@ Commands:
122
128
  active standard's vocabulary (RGAA "Thématique/Critère/Test", WCAG core
123
129
  "Principle·Guideline/Success criterion/Technique") — theme, criterion +
124
130
  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).
131
+ verification. --split criterion writes one file per criterion.
132
+ It writes MARKDOWN only — filing tickets is the 'tickets' command.
133
+ tickets File the audit as TRACKER TICKETS — GitHub, GitLab or Jira. Reads an
134
+ audit.json and pushes; it writes no markdown, exactly as prd/report
135
+ write markdown and push nothing. --grain chooses what ONE ticket is:
136
+ per criterion (default), per page, per page+criterion, per file, or one
137
+ consolidated. De-dupe is by exact title, so re-running never duplicates.
138
+ --dry-run prints the plan without creating anything.
128
139
  render Get RENDERED HTML to audit (so component libraries like DSFR are
129
140
  checked as the HTML they emit, not their JSX sources): detect the
130
141
  framework and print the build→audit recipe, or --scaffold a
@@ -292,11 +303,14 @@ Options:
292
303
  plus <out>/index.md (recommended past 3–4 pages: RGAA is 106 criteria)
293
304
  --no-technical prd (audit format): omit the technical ticket sections (Partie
294
305
  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
306
+ --provider <id> tickets: auto|github|gitlab|jira. 'auto' reads ULTRA11Y_TICKET_PROVIDER,
307
+ then .ultra11yrc.json, then the git remote (Jira is never auto-detected)
308
+ --grain <mode> tickets: what one ticket is — criterion (default) | page | page-criterion | single | file
309
+ --transport <t> tickets: auto|cli|rest (mcp: stdio|http). 'auto' prefers the CLI (gh/glab),
310
+ falling back to REST when only a token is available. Jira is REST-only
311
+ --max-tickets <n> tickets: refuse to file more than n tickets in one run (default 200)
312
+ tickets --out <dir> also writes the tracker-agnostic set to
313
+ <dir>/issues-<date>.json, for a workflow engine to file itself
300
314
  --scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
301
315
  --setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
302
316
  --coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
@@ -406,6 +420,7 @@ export const COMMANDS = [
406
420
  "audit",
407
421
  "report",
408
422
  "prd",
423
+ "tickets",
409
424
  "render",
410
425
  "criteria",
411
426
  "check",
@@ -434,6 +449,9 @@ function isCommand(s: string | undefined): s is Command {
434
449
 
435
450
  const VALUE_FLAGS = new Set([
436
451
  "out",
452
+ "provider",
453
+ "grain",
454
+ "max-tickets",
437
455
  "in",
438
456
  "include",
439
457
  "exclude",
@@ -528,9 +546,6 @@ const BOOLEAN_FLAGS = new Set([
528
546
  "semantic",
529
547
  "manual",
530
548
  "no-technical",
531
- "gh-issues",
532
- "gh-single",
533
- "issues-json",
534
549
  "override",
535
550
  "local",
536
551
  "docker",
@@ -1561,58 +1576,265 @@ async function cmdPrd(p: ParsedArgs): Promise<number> {
1561
1576
  const json = p.flags.json === true;
1562
1577
  if (!json) for (const path of paths) console.log(path);
1563
1578
 
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`,
1579
+ // `prd` writes MARKDOWN. It files nothing: ticket creation is `ultra11y tickets`, which
1580
+ // reads the same audit.json and writes no markdown. That split is deliberate — you could
1581
+ // not previously push without also producing a document, nor produce a document without
1582
+ // risking a push.
1583
+ if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang) }, null, 2));
1584
+ return 0;
1585
+ }
1586
+
1587
+ /** Flags that USED to exist, mapped to what replaces them. Checked before the generic
1588
+ * unknown-flag warning so a removal is loud on the first run, not discovered later from an
1589
+ * empty tracker. Reusable for the next removal — one table, one place. */
1590
+ export const REMOVED_FLAGS: Record<string, string> = {
1591
+ "gh-issues": "ultra11y tickets --in <audit.json> --provider github --grain criterion",
1592
+ "gh-single": "ultra11y tickets --in <audit.json> --provider github --grain single",
1593
+ // The tracker-agnostic export moved WITH ticket filing: `tickets --out` writes the same
1594
+ // envelope (and a superset of the payload) at any grain, so an orchestrator still reads
1595
+ // one stable path — just not from the command that renders documents.
1596
+ "issues-json": "ultra11y tickets --in <audit.json> --out <dir> --grain criterion",
1597
+ };
1598
+
1599
+ /** Config keys that must NEVER appear in `.ultra11yrc.json` — a committed credential is a
1600
+ * leaked credential. Named explicitly rather than pattern-matched, so the error can say what
1601
+ * to do instead. */
1602
+ const SECRET_CONFIG_KEYS = ["token", "apiToken", "api_token", "password", "secret"];
1603
+
1604
+ async function cmdTickets(p: ParsedArgs): Promise<number> {
1605
+ const standard = stdOf(p, "tickets");
1606
+ if (standard === null) return 2;
1607
+ const inFlag = p.flags.in;
1608
+ if (typeof inFlag !== "string" || !inFlag) {
1609
+ console.error("ultra11y tickets: --in <audit.json> is required ('-' for stdin). Run `audit --out` first.");
1610
+ return 2;
1611
+ }
1612
+ const raw = inFlag === "-" ? await readStdin() : readInputFile(inFlag, "tickets", "--in");
1613
+ if (raw === null) return 2;
1614
+ let result: unknown;
1615
+ try {
1616
+ result = JSON.parse(raw);
1617
+ } catch {
1618
+ console.error("ultra11y tickets: --in is not valid JSON (expected an AuditResult).");
1619
+ return 2;
1620
+ }
1621
+ if (!isCurrentAudit(result)) {
1622
+ console.error("ultra11y tickets: input is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
1623
+ return 2;
1624
+ }
1625
+ const lang = resolveLang(p.flags, { audit: result, standard });
1626
+ const fr = lang === "fr";
1627
+ const json = p.flags.json === true;
1628
+ const dryRun = p.flags["dry-run"] === true;
1629
+
1630
+ let config: Ultra11yConfig["tickets"];
1631
+ try {
1632
+ config = loadConfig(process.cwd())?.tickets;
1633
+ } catch (e) {
1634
+ console.error(`ultra11y tickets: ${e instanceof Error ? e.message : String(e)}`);
1635
+ return 2;
1636
+ }
1637
+ for (const key of SECRET_CONFIG_KEYS) {
1638
+ if (config && key in (config as Record<string, unknown>)) {
1639
+ console.error(
1640
+ `ultra11y tickets: .ultra11yrc.json must not carry a "${key}" — credentials belong in the environment (see references/tickets.md). Remove it and rotate that value.`,
1641
+ );
1642
+ return 2;
1643
+ }
1644
+ }
1645
+
1646
+ // --- usage, before any I/O --------------------------------------------------------------
1647
+ // Everything decidable from the argv alone is decided HERE, ahead of provider resolution.
1648
+ // Resolving a provider spawns `git config --get remote.origin.url`, and an `auto`
1649
+ // transport probes `gh`/`glab auth status` — which reaches the network. A mistyped
1650
+ // `--grain` should never cost a subprocess, let alone a network round-trip, and the error
1651
+ // the caller gets should name what they typed rather than a tracker they never mentioned.
1652
+ //
1653
+ // It was also a real flake: `tickets --grain nope` spent its whole 5s test budget in those
1654
+ // probes on a loaded CI runner, and the release gate went red on a purely syntactic error.
1655
+ const grainFlag = typeof p.flags.grain === "string" ? (p.flags.grain as string) : (config?.grain ?? "criterion");
1656
+ if (!(ALL_GRAINS as readonly string[]).includes(grainFlag)) {
1657
+ console.error(`ultra11y tickets: --grain must be one of ${ALL_GRAINS.join("|")} (got "${grainFlag}").`);
1658
+ return 2;
1659
+ }
1660
+ const transportFlag = typeof p.flags.transport === "string" ? (p.flags.transport as string) : (config?.transport ?? "auto");
1661
+ if (!["auto", "cli", "rest"].includes(transportFlag)) {
1662
+ console.error(`ultra11y tickets: --transport must be auto, cli or rest (got "${transportFlag}").`);
1663
+ return 2;
1664
+ }
1665
+ const maxTickets = Number.parseInt(String(p.flags["max-tickets"] ?? config?.maxTickets ?? 200), 10);
1666
+ if (!Number.isFinite(maxTickets) || maxTickets < 1) {
1667
+ console.error("ultra11y tickets: --max-tickets must be a positive integer.");
1668
+ return 2;
1669
+ }
1670
+
1671
+ // --- provider ---------------------------------------------------------------------------
1672
+ const providerFlag = typeof p.flags.provider === "string" ? (p.flags.provider as string) : "auto";
1673
+ const providerId = providerFlag === "auto" ? autoProvider(process.env, config?.provider) : isProviderId(providerFlag) ? providerFlag : undefined;
1674
+ if (!providerId) {
1675
+ console.error(
1676
+ providerFlag === "auto"
1677
+ ? "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)."
1678
+ : `ultra11y tickets: --provider "${providerFlag}" is not one of ${ALL_PROVIDERS.join("|")}.`,
1577
1679
  );
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
- }
1680
+ return 2;
1681
+ }
1682
+
1683
+ const provider = createProvider(providerId, { transport: transportFlag as TransportMode });
1684
+
1685
+ // --- the plan ---------------------------------------------------------------------------
1686
+ const format = p.flags.format === "remediation" ? "remediation" : "audit";
1687
+ const plan = buildTickets(result, {
1688
+ grain: grainFlag as TicketGrain,
1689
+ standard,
1690
+ lang,
1691
+ format,
1692
+ bodyLimit: provider.capabilities.bodyLimit,
1693
+ baseDir: process.cwd(),
1694
+ technical: p.flags["no-technical"] !== true,
1695
+ });
1696
+
1697
+ if (plan.error === "no-pages") {
1698
+ console.error(
1699
+ fr
1700
+ ? "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."
1701
+ : "ultra11y tickets: no page in scope. Capture snapshots (render --e2e) or scan a sample (scan --sample) before using --grain page.",
1702
+ );
1703
+ return 1;
1704
+ }
1705
+
1706
+ // A creation is hard to undo, and `page-criterion` on a large audit runs to the hundreds.
1707
+ // Refuse rather than flood somebody's tracker; never silently truncate.
1708
+ if (plan.tickets.length > maxTickets) {
1709
+ console.error(
1710
+ fr
1711
+ ? `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.`
1712
+ : `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.`,
1713
+ );
1714
+ return 2;
1715
+ }
1716
+
1717
+ // `--out` writes the tracker-agnostic SET to a stable path, for a workflow engine that
1718
+ // files the items itself. It is not a document — it is the ticket payload — which is why
1719
+ // it lives here rather than on `prd`. Writing it never files anything.
1720
+ const ticketsOut = typeof p.flags.out === "string" && p.flags.out ? (p.flags.out as string) : undefined;
1721
+ let setPath: string | undefined;
1722
+ if (ticketsOut) {
1723
+ mkdirSync(ticketsOut, { recursive: true });
1724
+ setPath = join(ticketsOut, `issues-${result.date}.json`);
1725
+ const payload: TicketSetFile = {
1726
+ tool: "ultra11y",
1727
+ kind: "issues",
1728
+ schemaVersion: TICKET_SET_SCHEMA_VERSION,
1729
+ standard,
1730
+ grain: grainFlag as TicketGrain,
1731
+ date: result.date,
1732
+ count: plan.tickets.length,
1733
+ issues: plan.tickets,
1734
+ };
1735
+ writeFileSync(setPath, `${JSON.stringify(payload, null, 2)}\n`);
1736
+ if (!json) console.log(setPath);
1737
+ }
1738
+
1739
+ if (!plan.tickets.length) {
1740
+ if (!json)
1741
+ console.log(
1742
+ fr
1743
+ ? "ultra11y tickets : rien à déposer — l'audit ne relève aucune non-conformité."
1744
+ : "ultra11y tickets: nothing to file — the audit found no non-conformity.",
1745
+ );
1746
+ if (json)
1747
+ console.log(
1748
+ JSON.stringify(
1749
+ {
1750
+ provider: providerId,
1751
+ transport: provider.transport,
1752
+ grain: grainFlag,
1753
+ standard,
1754
+ dryRun,
1755
+ ...(setPath ? { setPath } : {}),
1756
+ tickets: [],
1757
+ unattributed: plan.unattributed,
1758
+ result: { created: 0, skipped: 0, failed: 0, createdTitles: [], createdUrls: [], errors: [] },
1759
+ },
1760
+ null,
1761
+ 2,
1762
+ ),
1763
+ );
1764
+ return 0;
1765
+ }
1766
+
1767
+ // A push command that files nothing and reports green is a silent failure — unlike the old
1768
+ // `prd --gh-issues`, whose push was an optional extra on top of a document.
1769
+ if (!provider.available() && !dryRun) {
1770
+ console.error(`ultra11y tickets: ${providerId} is not usable here — ${provider.unavailableReason()}.`);
1771
+ return 1;
1772
+ }
1773
+
1774
+ const { plan: planned, result: pushed, dedupeChecked } = await pushTickets(plan.tickets, provider, { dryRun: dryRun || !provider.available() });
1775
+
1776
+ if (json) {
1777
+ console.log(
1778
+ JSON.stringify(
1779
+ {
1780
+ provider: providerId,
1781
+ transport: provider.transport,
1782
+ grain: grainFlag,
1783
+ standard,
1784
+ dryRun,
1785
+ ...(setPath ? { setPath } : {}),
1786
+ dedupeChecked,
1787
+ tickets: planned.map(({ ticket, action }) => ({
1788
+ title: ticket.title,
1789
+ labels: ticket.labels,
1790
+ severity: ticket.severity,
1791
+ advisory: ticket.advisory,
1792
+ scope: ticket.scope,
1793
+ bodyChars: ticket.body.length,
1794
+ action,
1795
+ // The body rides along ONLY under --dry-run: an agent inspecting the plan wants
1796
+ // it, a 200-ticket push payload does not.
1797
+ ...(dryRun ? { body: ticket.body } : {}),
1798
+ })),
1799
+ unattributed: plan.unattributed,
1800
+ result: pushed,
1801
+ },
1802
+ null,
1803
+ 2,
1804
+ ),
1805
+ );
1806
+ } else if (dryRun || !provider.available()) {
1807
+ const create = planned.filter((x) => x.action === "create").length;
1808
+ console.log(
1809
+ fr
1810
+ ? `ultra11y tickets : simulation (${providerId}/${provider.transport}, grain ${grainFlag}) — ${create} à créer, ${pushed.skipped} déjà présent(s).`
1811
+ : `ultra11y tickets: dry run (${providerId}/${provider.transport}, grain ${grainFlag}) — ${create} to create, ${pushed.skipped} already there.`,
1812
+ );
1813
+ for (const { ticket, action } of planned) console.log(` ${action === "create" ? "+" : "="} ${ticket.title}`);
1814
+ if (!dedupeChecked)
1815
+ console.error(
1816
+ fr
1817
+ ? "ultra11y tickets : l'existant n'a pas pu être listé — le compte « déjà présent » n'est pas vérifié."
1818
+ : 'ultra11y tickets: could not list existing tickets — the "already there" count is unverified.',
1819
+ );
1820
+ if (!provider.available()) console.error(`ultra11y tickets: ${providerId} — ${provider.unavailableReason()}.`);
1821
+ } else {
1822
+ console.log(
1823
+ fr
1824
+ ? `ultra11y tickets : ${providerId} — ${pushed.created} créé(s), ${pushed.skipped} déjà présent(s)${pushed.failed ? `, ${pushed.failed} en échec` : ""}.`
1825
+ : `ultra11y tickets: ${providerId} — ${pushed.created} created, ${pushed.skipped} already there${pushed.failed ? `, ${pushed.failed} failed` : ""}.`,
1826
+ );
1827
+ for (const u of pushed.createdUrls) console.log(` ${u}`);
1828
+ for (const e of pushed.errors) console.error(fr ? `ultra11y tickets : échec — ${e}` : `ultra11y tickets: failed — ${e}`);
1829
+ if (plan.unattributed) {
1830
+ console.error(
1831
+ fr
1832
+ ? `ultra11y tickets : ${plan.unattributed} constat(s) non rattaché(s) à une page — déposés dans leur propre ticket, jamais répartis d'office.`
1833
+ : `ultra11y tickets: ${plan.unattributed} finding(s) attributed to no page — filed as their own ticket, never spread.`,
1834
+ );
1608
1835
  }
1609
1836
  }
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;
1837
+ return pushed.failed > 0 ? 1 : 0;
1616
1838
  }
1617
1839
 
1618
1840
  /** Merged dependencies of the package.json at `root` (empty when absent/unparseable — the
@@ -2867,6 +3089,16 @@ export async function main(argv: string[]): Promise<number> {
2867
3089
  console.log(HELP);
2868
3090
  return 0;
2869
3091
  }
3092
+ // A REMOVED flag must not fall through to the generic "unknown flag (ignored)" warning
3093
+ // below: a scripted CI would keep exiting 0 while filing nothing at all — precisely the
3094
+ // silent failure this release exists to end. Name the replacement and fail.
3095
+ for (const f of p.unknown) {
3096
+ const replacement = REMOVED_FLAGS[f];
3097
+ if (replacement) {
3098
+ console.error(`ultra11y: --${f} was removed in v3 — ticket creation is now its own command. Use: ${replacement}`);
3099
+ return 2;
3100
+ }
3101
+ }
2870
3102
  // Warn (never silently ignore) on misspelled/unknown flags so `--grph` or
2871
3103
  // `--standrd rgaa` can't quietly leave cross-file/a standard disabled.
2872
3104
  for (const f of p.unknown) console.error(`ultra11y: unknown flag --${f} (ignored). Run \`ultra11y --help\`.`);
@@ -2885,6 +3117,11 @@ export async function main(argv: string[]): Promise<number> {
2885
3117
  format: ["audit", "doc", "remediation", "sarif", "github", "grid", "report"],
2886
3118
  split: ["criterion", "page"],
2887
3119
  runtime: ["auto", "local", "docker"],
3120
+ provider: ["auto", "github", "gitlab", "jira"],
3121
+ grain: ["criterion", "page", "page-criterion", "single", "file"],
3122
+ // The union over every command that takes it: `mcp` serves stdio|http, `tickets`
3123
+ // picks cli|rest. Listing one command's values made the guard cry wolf on the other.
3124
+ transport: ["stdio", "http", "auto", "cli", "rest"],
2888
3125
  };
2889
3126
  for (const [flag, allowed] of Object.entries(ENUM_FLAGS)) {
2890
3127
  const v = p.flags[flag];
@@ -2913,6 +3150,8 @@ export async function main(argv: string[]): Promise<number> {
2913
3150
  return cmdAudit(p);
2914
3151
  case "report":
2915
3152
  return cmdReport(p);
3153
+ case "tickets":
3154
+ return cmdTickets(p);
2916
3155
  case "prd":
2917
3156
  return cmdPrd(p);
2918
3157
  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));
package/src/mcp/tools.ts CHANGED
@@ -89,6 +89,28 @@ export const TOOLS: ToolDecl[] = [
89
89
  required: ["cwd"],
90
90
  },
91
91
  },
92
+ {
93
+ name: "ultra11y_tickets",
94
+ title: "Preview the tracker tickets an audit would file",
95
+ description:
96
+ "Show the GitHub/GitLab/Jira tickets this audit would open, at a granularity you choose: one per criterion (default), per page, per page+criterion, per " +
97
+ "file, or one consolidated. Returns the plan ONLY — it never creates anything; filing stays a deliberate `ultra11y tickets` run.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ cwd: cwdProp,
102
+ globs: globsProp,
103
+ standard: standardProp,
104
+ lang: langProp,
105
+ grain: {
106
+ type: "string",
107
+ enum: ["criterion", "page", "page-criterion", "single", "file"],
108
+ description: "What ONE ticket is. Default 'criterion'.",
109
+ },
110
+ },
111
+ required: ["cwd"],
112
+ },
113
+ },
92
114
  {
93
115
  name: "ultra11y_criteria",
94
116
  title: "The offline standards reference",
@@ -302,6 +324,11 @@ export const TOOL_META: Record<string, { write?: boolean; destructive?: boolean;
302
324
  ultra11y_audit: { openWorld: false },
303
325
  ultra11y_report: { openWorld: false },
304
326
  ultra11y_prd: { openWorld: false },
327
+ // READ-ONLY and closed-world by construction: it returns the ticket PLAN and never files
328
+ // anything, so it neither touches the project nor reaches a tracker. Filing stays a
329
+ // deliberate `ultra11y tickets` run — an agent must not be able to open issues in
330
+ // somebody's tracker off the back of a prompt injection.
331
+ ultra11y_tickets: { openWorld: false },
305
332
  ultra11y_criteria: { openWorld: false },
306
333
  ultra11y_check: { openWorld: false },
307
334
  ultra11y_verify: { openWorld: false },
package/src/pages.ts CHANGED
@@ -225,6 +225,19 @@ const L = {
225
225
  },
226
226
  } as const;
227
227
 
228
+ /** Honesty rule 2, as one sentence: a page with no snapshot cannot earn conformity by
229
+ * silence. Exported so the grid, the per-page report AND the page-grain ticket all say the
230
+ * SAME thing — one string, several readers, no drift. Undefined for a snapshot page, which
231
+ * needs no caveat. */
232
+ export function pageBasisWarning(basis: PageScope["basis"], lang: Lang): string | undefined {
233
+ return basis === "snapshot" ? undefined : L[lang].basisNote;
234
+ }
235
+
236
+ /** Honesty rule 1, as one sentence: findings no page could claim are reported, never spread. */
237
+ export function unattributedNote(n: number, lang: Lang): string {
238
+ return L[lang].unattributed(n);
239
+ }
240
+
228
241
  interface Row {
229
242
  id: string;
230
243
  label: string;