@ultimat3/cli 6.0.0 → 8.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 (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
package/src/cmd-new.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { chmod } from 'node:fs/promises';
7
7
  import { isAbsolute, join, resolve } from 'node:path';
8
+ import { renderThrowable } from '@ultimat3/core';
8
9
  import { dedupe } from './cmd-generate';
9
10
  import type { CliCommand, CommandContext } from './command';
10
11
  import { MissingPositionalError } from './errors';
12
+ import type { Runner } from './exec';
11
13
  import { msg } from './messages';
12
14
  import type { CommandResult } from './output';
13
15
  import { flagBool, flagString } from './parse';
@@ -21,6 +23,70 @@ export interface NewAppOptions {
21
23
  readonly example: boolean;
22
24
  }
23
25
 
26
+ /** `--no-git`'s `problem`, matched rather than re-spelled where the report decides on a line. */
27
+ export const SKIPPED = 'skipped by --no-git';
28
+
29
+ /**
30
+ * What `git init && git add -A && git commit` did, reported on `data.git` either way.
31
+ *
32
+ * A type alias and not an `interface`, because it IS `CommandResult.data`: an interface has no
33
+ * implicit index signature, so it is not assignable to `JsonValue` and the `--json` contract would
34
+ * not compile (TS2322).
35
+ */
36
+ export type RepositoryInit = {
37
+ readonly initialized: boolean;
38
+ readonly committed: boolean;
39
+ /**
40
+ * Why not — `null` when both halves ran. Required rather than optional, so `data.git` has one
41
+ * shape for a machine reading `--json`: a key that appears only on failure is a key every
42
+ * consumer has to guess at. Never a raw thrown value; `renderThrowable` writes it.
43
+ */
44
+ readonly problem: string | null;
45
+ };
46
+
47
+ /**
48
+ * Plain `git init`, with no `--initial-branch`: that flag is git 2.28+, and a scaffold that failed
49
+ * on an older git would trade a working tree for a branch name. The repository takes whatever
50
+ * `init.defaultBranch` this machine already agreed on.
51
+ */
52
+ const GIT_STEPS: readonly (readonly string[])[] = [
53
+ ['git', 'init'],
54
+ ['git', 'add', '-A'],
55
+ ['git', 'commit', '-m', 'x new'],
56
+ ];
57
+
58
+ /** What `--no-git` records, so `data.git` has the same shape whichever way the flag went. */
59
+ const NO_GIT: RepositoryInit = { initialized: false, committed: false, problem: SKIPPED };
60
+
61
+ /**
62
+ * A scaffold is a REPOSITORY, because three surfaces of this CLI already assume one and answered
63
+ * `not a git repository` in a fresh app: `x affected`, `x ci` and `x pr`. It was four —
64
+ * `X_ROUTE_FILE_INVALID`'s `fix:` was a `git mv` that exits 128 with no `.git` to run in, and that
65
+ * one is now a plain `mv -n` (`packages/render/src/registry.ts`) rather than a reason to init.
66
+ *
67
+ * It never fails `x new`. The command's job is the tree, that tree is on disk by the time this
68
+ * runs, and a box with no `git` or no configured `user.email` would otherwise get an app it cannot
69
+ * see — so the outcome is DATA, and a failure is one line naming the commands to run by hand.
70
+ */
71
+ export async function initRepository(runner: Runner, dir: string): Promise<RepositoryInit> {
72
+ let initialized = false;
73
+ for (const command of GIT_STEPS) {
74
+ try {
75
+ const result = await runner(command, { cwd: dir });
76
+ if (!result.ok) {
77
+ const detail = (result.stderr.trim() || result.stdout.trim()).split('\n')[0] ?? '';
78
+ return { initialized, committed: false, problem: `${command.join(' ')}: ${detail}` };
79
+ }
80
+ } catch (error) {
81
+ // The thrown value is genuinely unknown — `exec` refuses a missing program by throwing —
82
+ // and core's renderer is the one spelling that cannot itself throw on a hostile `toString`.
83
+ return { initialized, committed: false, problem: renderThrowable(error) };
84
+ }
85
+ initialized = true;
86
+ }
87
+ return { initialized: true, committed: true, problem: null };
88
+ }
89
+
24
90
  /** Pure: the complete file list for a new app, so `--dry-run` and the test see the same thing. */
25
91
  export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
26
92
  const app = names(options.name);
@@ -68,13 +134,25 @@ export const newCommand: CliCommand = {
68
134
  spec: {
69
135
  name: 'new',
70
136
  summary: 'scaffold a new Ultimate monorepo that already runs',
71
- usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--force] [--json]',
137
+ // Every flag the table below declares, in the spelling that turns it off where the default is
138
+ // on: the usage line offered `--no-example` while the table listed `--example`, and a reader
139
+ // had to reconcile the two to answer "which one do I get if I type neither".
140
+ usage: 'x new <name> [--dir path] [--no-example] [--no-git] [--dry-run] [--force] [--json]',
72
141
  flags: [
73
142
  { name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
74
143
  {
144
+ // The summary carries the default and the negation because the page has to answer "which
145
+ // one do I get if I type neither": the usage line offered `--no-example`, this table said
146
+ // `--example`, and `default: true` is a field only `--json` renders. 134 files against 107.
75
147
  name: 'example',
76
148
  type: 'boolean',
77
- summary: 'include the example feature slice',
149
+ summary: 'include the example feature slice (default: on; --no-example for an empty app/)',
150
+ default: true,
151
+ },
152
+ {
153
+ name: 'git',
154
+ type: 'boolean',
155
+ summary: 'git init and commit the scaffold (default: on; --no-git for a bare directory)',
78
156
  default: true,
79
157
  },
80
158
  { name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
@@ -124,12 +202,21 @@ export const newCommand: CliCommand = {
124
202
  };
125
203
  }
126
204
  const written = await writeNewApp(target, options);
205
+ const git =
206
+ ctx.args.flags.get('git') === false ? NO_GIT : await initRepository(ctx.runner, target);
207
+ const lines = [msg('cli.new.wrote', { count: written.files.length, dir: target })];
208
+ if (git.problem !== null && git.problem !== SKIPPED) {
209
+ lines.push(msg('cli.new.noRepository', { problem: git.problem }));
210
+ // Raw, unlike the two prose lines around it: this one is an instruction to run verbatim, and
211
+ // a translated command is a broken one (`packages/cli/CLAUDE.md`).
212
+ lines.push(` run: cd ${target} && git init && git add -A && git commit -m 'x new'`);
213
+ }
127
214
  return {
128
215
  ok: true,
129
216
  command: 'new',
130
217
  summary: msg('cli.new.done', { name: app.kebab }),
131
- data: { dir: written.dir, files: written.files },
132
- lines: [` ${written.files.length} files in ${target}`],
218
+ data: { dir: written.dir, files: written.files, git },
219
+ lines,
133
220
  };
134
221
  },
135
222
  };
package/src/cmd-policy.ts CHANGED
@@ -2,13 +2,14 @@
2
2
  // only: the fact-gathering (registries, matrix rows) lives in `policy-facts.ts`, so the matrix
3
3
  // logic is testable without an app — same split as `cmd-jobs.ts` / `jobs-report.ts`.
4
4
 
5
+ import { nearestName } from '@ultimat3/core';
5
6
  import { loadApp } from './app-load';
6
7
  import { requireAppRoot } from './app-root';
7
8
  import type { CliCommand, CommandContext } from './command';
8
9
  import { DeclarationUnknownError, MissingPositionalError } from './errors';
9
10
  import { msg } from './messages';
10
11
  import type { CommandResult, Finding, JsonValue } from './output';
11
- import { nearest } from './parse';
12
+
12
13
  import type { DeclarationExplanation } from './policy-facts';
13
14
  import { explainPolicy, knownPolicySubjects, listPolicy } from './policy-facts';
14
15
  import { renderTable } from './table';
@@ -91,7 +92,7 @@ function runExplain(ctx: CommandContext, findings: readonly Finding[]): CommandR
91
92
  const explanation = explainPolicy(name);
92
93
  if (explanation === undefined) {
93
94
  const known = knownPolicySubjects();
94
- const suggestion = nearest(name, known);
95
+ const suggestion = nearestName(name, known);
95
96
  throw new DeclarationUnknownError(
96
97
  suggestion === undefined
97
98
  ? { kind: 'policy', singular: 'policy subject', name, known, verb: 'explain' }
package/src/cmd-pr.ts ADDED
@@ -0,0 +1,359 @@
1
+ // `x pr review|resolve|reply` — the inline review, in a terminal. `gh pr view --comments` prints
2
+ // ISSUE comments, so the findings a reviewer anchored to a line are invisible to it and the thread
3
+ // id needed to resolve one exists nowhere a human can copy. This command is the one place that
4
+ // query lives, and the one place the two facts a reviewer's verdict hides behind are stated: a
5
+ // `reviewDecision` outlives the push that answered it, and a RESOLVED thread is a closed
6
+ // conversation rather than a fixed finding.
7
+
8
+ import type { CliCommand, CommandContext } from './command';
9
+ import {
10
+ BadFlagError,
11
+ MissingPositionalError,
12
+ MissingSubcommandError,
13
+ UnknownCommandError,
14
+ } from './errors';
15
+ import { parseIntFlag } from './flag-number';
16
+ import { PrNotFoundError, resolvePrNumber, resolveRepo } from './gh-target';
17
+ import { msg } from './messages';
18
+ import type { CommandResult, JsonValue } from './output';
19
+ import { flagBool, flagString } from './parse';
20
+ import type { PrReviewReport, PrThread } from './pr-threads';
21
+ import { fetchReviewReport, replyToThread, resolveThread, THREAD_PAGE } from './pr-threads';
22
+
23
+ export const PR_SUBCOMMANDS = ['review', 'resolve', 'reply'] as const;
24
+
25
+ /**
26
+ * Every catalog key this command renders, declared. `msg()` answers `⟦key⟧` for a key nobody
27
+ * added — loud in a terminal and SILENT to a build — so the list is exported and
28
+ * `cmd-pr.test.ts` holds it against the catalog, which turns a missing string into a failing test
29
+ * instead of a rendered artefact of one.
30
+ */
31
+ export const PR_MESSAGE_KEYS = [
32
+ 'cli.pr.review.count',
33
+ 'cli.pr.review.none',
34
+ 'cli.pr.review.decision',
35
+ 'cli.pr.review.stale',
36
+ 'cli.pr.review.current',
37
+ 'cli.pr.review.undecided',
38
+ 'cli.pr.review.truncated',
39
+ 'cli.pr.thread.open',
40
+ 'cli.pr.thread.closed',
41
+ 'cli.pr.thread.outdated',
42
+ 'cli.pr.thread.comment',
43
+ 'cli.pr.thread.more',
44
+ 'cli.pr.body.truncated',
45
+ 'cli.pr.line.unknown',
46
+ 'cli.pr.resolved',
47
+ 'cli.pr.replied',
48
+ ] as const;
49
+
50
+ /** Lines of each comment body shown before it is cut. CodeRabbit's run to several thousand. */
51
+ export const BODY_LINES = 20;
52
+
53
+ export const prCommand: CliCommand = {
54
+ spec: {
55
+ name: 'pr',
56
+ summary: 'inline review threads: list them with their ids, resolve one, reply in one',
57
+ usage:
58
+ 'x pr review [--pr <n>] [--repo owner/name] [--all] [--full] | x pr resolve <thread-id> | x pr reply <thread-id> --body "…"',
59
+ subcommands: PR_SUBCOMMANDS,
60
+ flags: [
61
+ { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
62
+ { name: 'pr', type: 'string', summary: 'pull request number; this branch own by default' },
63
+ // Scoped to the subcommand each summary already names: `resolve` and `reply` WRITE to
64
+ // somebody else's pull request, and a flag they silently ignore is a flag whose caller
65
+ // believed it did something to a request that cannot be re-run.
66
+ {
67
+ name: 'all',
68
+ type: 'boolean',
69
+ summary: 'review: resolved threads too, not just open ones',
70
+ subcommands: ['review'],
71
+ },
72
+ {
73
+ name: 'full',
74
+ type: 'boolean',
75
+ summary: 'review: whole comment bodies, never truncated',
76
+ subcommands: ['review'],
77
+ },
78
+ {
79
+ name: 'body',
80
+ type: 'string',
81
+ summary: 'reply: the comment text to post in the thread',
82
+ subcommands: ['reply'],
83
+ },
84
+ ],
85
+ },
86
+ async run(ctx: CommandContext): Promise<CommandResult> {
87
+ // No `defaultSubcommand`: `resolve` and `reply` both WRITE to a pull request, so "whatever the
88
+ // caller left out" is not a safe guess for any of the three.
89
+ const sub = ctx.args.subcommand;
90
+ if (sub === undefined) {
91
+ throw new MissingSubcommandError({ command: 'pr', known: PR_SUBCOMMANDS });
92
+ }
93
+ if (sub === 'review') return runReview(ctx);
94
+ if (sub === 'resolve') return runResolve(ctx);
95
+ if (sub === 'reply') return runReply(ctx);
96
+ throw new UnknownCommandError({
97
+ path: `pr ${sub}`,
98
+ known: PR_SUBCOMMANDS,
99
+ suggestion: 'help pr',
100
+ });
101
+ },
102
+ };
103
+
104
+ /** `--pr` bounds, declared once so the refusal and the resolution cannot disagree about them. */
105
+ const PR_NUMBER = {
106
+ name: 'pr',
107
+ command: 'pr review',
108
+ min: 1,
109
+ example: 'x pr review --pr 241 --json',
110
+ } as const;
111
+
112
+ async function runReview(ctx: CommandContext): Promise<CommandResult> {
113
+ // 'pr review', not 'pr': `resolveRepo` builds its refusal's `fix:` from this word, and
114
+ // `x pr --repo …` is a command that throws `MissingSubcommandError` — a fix line that
115
+ // reproduces its own failure is the shape `MissingSubcommandError`'s own doc block warns about.
116
+ const repo = await resolveRepo(ctx, 'pr review', flagString(ctx.args, 'repo'));
117
+ const raw = flagString(ctx.args, 'pr');
118
+ const number =
119
+ raw === undefined ? await resolvePrNumber(ctx, repo) : parseIntFlag(raw, PR_NUMBER);
120
+ const report = await fetchReviewReport(ctx, repo, number);
121
+ if (report === null) {
122
+ throw new PrNotFoundError({ detail: `${repo.slug}#${number} answered no pull request` });
123
+ }
124
+ const all = flagBool(ctx.args, 'all');
125
+ const shown = all ? report.threads : report.threads.filter((thread) => !thread.isResolved);
126
+ const bodyLines = flagBool(ctx.args, 'full') ? Number.POSITIVE_INFINITY : BODY_LINES;
127
+ const unresolved = report.threads.filter((thread) => !thread.isResolved).length;
128
+ return {
129
+ // An inspection command, so the verdict is "the report was produced" and never "the review is
130
+ // clean": an agent loops read → edit → resolve on this output, and a non-zero exit for every
131
+ // open thread would report the work still to do as a failure of the command that listed it.
132
+ ok: true,
133
+ command: 'pr',
134
+ summary:
135
+ report.threads.length === 0
136
+ ? msg('cli.pr.review.none', { repo: report.repo, pr: report.number })
137
+ : msg('cli.pr.review.count', {
138
+ unresolved,
139
+ resolved: report.threads.length - unresolved,
140
+ repo: report.repo,
141
+ pr: report.number,
142
+ }),
143
+ lines: [...decisionLines(report), ...shown.flatMap((thread) => threadLines(thread, bodyLines))],
144
+ data: reviewJson(report, shown, bodyLines, unresolved),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * The staleness hazard, rendered before the threads. A `reviewDecision` survives every later push,
150
+ * so `CHANGES_REQUESTED` on a branch that has since been fixed reads exactly like one that has
151
+ * not — and the answer is the commit the review was submitted against, which is a fact rather than
152
+ * a timestamp comparison across a push.
153
+ */
154
+ function decisionLines(report: PrReviewReport): readonly string[] {
155
+ const review = report.decidingReview;
156
+ if (review === null) {
157
+ return [msg('cli.pr.review.undecided'), ...truncationLines(report)];
158
+ }
159
+ return [
160
+ msg('cli.pr.review.decision', {
161
+ decision: report.reviewDecision ?? review.state,
162
+ author: review.author,
163
+ submitted: review.submittedAt ?? '',
164
+ }),
165
+ review.stale
166
+ ? msg('cli.pr.review.stale', {
167
+ commit: short(review.commit),
168
+ head: short(report.headSha),
169
+ committed: report.headCommittedAt ?? '',
170
+ })
171
+ : msg('cli.pr.review.current', { head: short(report.headSha) }),
172
+ ...truncationLines(report),
173
+ ];
174
+ }
175
+
176
+ const truncationLines = (report: PrReviewReport): readonly string[] =>
177
+ report.truncated ? [msg('cli.pr.review.truncated', { count: THREAD_PAGE })] : [];
178
+
179
+ /** Seven characters is what every GitHub UI shows, and enough to paste into `git show`. */
180
+ const short = (sha: string | null): string => (sha === null ? '' : sha.slice(0, 7));
181
+
182
+ const lineOf = (thread: PrThread): string =>
183
+ thread.line === null
184
+ ? (thread.originalLine?.toString() ?? msg('cli.pr.line.unknown'))
185
+ : String(thread.line);
186
+
187
+ function threadLines(thread: PrThread, bodyLines: number): readonly string[] {
188
+ const head = thread.isResolved ? 'cli.pr.thread.closed' : 'cli.pr.thread.open';
189
+ const out = [msg(head, { path: thread.path, line: lineOf(thread), id: thread.id })];
190
+ if (thread.isOutdated) out.push(msg('cli.pr.thread.outdated', { line: lineOf(thread) }));
191
+ for (const comment of thread.comments) {
192
+ out.push(
193
+ msg('cli.pr.thread.comment', { author: comment.author, createdAt: comment.createdAt }),
194
+ );
195
+ const clamped = clampBody(comment.body, bodyLines);
196
+ out.push(...commentBlock(thread.id, clamped.lines));
197
+ if (clamped.hidden > 0) out.push(msg('cli.pr.body.truncated', { hidden: clamped.hidden }));
198
+ }
199
+ const hidden = thread.commentCount - thread.comments.length;
200
+ if (hidden > 0) out.push(msg('cli.pr.thread.more', { hidden }));
201
+ return out;
202
+ }
203
+
204
+ const BLOCK_OPEN = '<comment id=';
205
+ const BLOCK_CLOSE = '</comment>';
206
+
207
+ /**
208
+ * The fence, as a READER would parse it rather than as this file spells it.
209
+ *
210
+ * Two literal `replaceAll`s were the whole neutralisation, and markup is not spelled one way:
211
+ * `</comment >`, `</COMMENT>` and `< comment id=` all end or open a block for anything reading
212
+ * tags, and none of the three matched. One pattern over `<`, an optional `/`, and whitespace
213
+ * around a case-insensitive `comment` covers every spelling of the delimiter; the escape goes on
214
+ * the `<`, so what the reviewer wrote after it survives byte for byte.
215
+ */
216
+ const BLOCK_DELIMITER = /<(\s*\/?\s*comment\b)/gi;
217
+
218
+ /**
219
+ * One comment body, fenced and labelled with the thread id it came from — `@ultimat3/ai`'s
220
+ * `documentBlock` (`rag.ts`), applied to the other place foreign text enters an agent's context.
221
+ * `x pr review` exists because an agent cannot read the GitHub web UI, and a review body is
222
+ * written by anyone who can comment on the pull request: rendered as bare indented text it arrived
223
+ * in that agent's context indistinguishable from the command's own output, which is prompt
224
+ * injection with a shell attached.
225
+ *
226
+ * The fence is neutralised INSIDE the payload rather than deleted, so every word the reviewer
227
+ * wrote still reads, and the label is stripped of the three characters that would end the
228
+ * attribute. Influence only, and deliberately not sold as more: a fence tells a reader this text
229
+ * is data, and it can never stop one that decides otherwise.
230
+ */
231
+ export function commentBlock(id: string, lines: readonly string[]): readonly string[] {
232
+ const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
233
+ const body = lines.map((line) => line.replace(BLOCK_DELIMITER, '<\\$1'));
234
+ return [`${BLOCK_OPEN}"${label}">`, ...body, BLOCK_CLOSE];
235
+ }
236
+
237
+ /**
238
+ * A review body is prose written for a browser: the ones in this repo run to six thousand
239
+ * characters with a shell script embedded in each. Clamped in ONE place, so `--json` carries the
240
+ * same bytes the terminal shows and `--full` moves both — a `lines` render that carried less than
241
+ * the data would be two reports of one review.
242
+ */
243
+ export function clampBody(
244
+ body: string,
245
+ limit: number,
246
+ ): { readonly lines: readonly string[]; readonly hidden: number } {
247
+ const lines = body.replaceAll('\r\n', '\n').split('\n');
248
+ if (lines.length <= limit) return { lines, hidden: 0 };
249
+ return { lines: lines.slice(0, limit), hidden: lines.length - limit };
250
+ }
251
+
252
+ function reviewJson(
253
+ report: PrReviewReport,
254
+ shown: readonly PrThread[],
255
+ bodyLines: number,
256
+ unresolved: number,
257
+ ): JsonValue {
258
+ const review = report.decidingReview;
259
+ return {
260
+ repo: report.repo,
261
+ pr: report.number,
262
+ url: report.url,
263
+ headSha: report.headSha,
264
+ headCommittedAt: report.headCommittedAt,
265
+ reviewDecision: report.reviewDecision,
266
+ review:
267
+ review === null
268
+ ? null
269
+ : {
270
+ state: review.state,
271
+ author: review.author,
272
+ submittedAt: review.submittedAt,
273
+ commit: review.commit,
274
+ // The hazard as a field, because an agent reading `--json` never sees the line above.
275
+ stale: review.stale,
276
+ },
277
+ counts: {
278
+ total: report.threads.length,
279
+ unresolved,
280
+ resolved: report.threads.length - unresolved,
281
+ shown: shown.length,
282
+ },
283
+ truncated: report.truncated,
284
+ threads: shown.map((thread) => {
285
+ const comments = thread.comments.map((comment) => {
286
+ const clamped = clampBody(comment.body, bodyLines);
287
+ return {
288
+ author: comment.author,
289
+ createdAt: comment.createdAt,
290
+ body: clamped.lines.join('\n'),
291
+ bodyLinesHidden: clamped.hidden,
292
+ };
293
+ });
294
+ return {
295
+ id: thread.id,
296
+ path: thread.path,
297
+ line: thread.line,
298
+ originalLine: thread.originalLine,
299
+ isResolved: thread.isResolved,
300
+ isOutdated: thread.isOutdated,
301
+ commentCount: thread.commentCount,
302
+ comments,
303
+ };
304
+ }),
305
+ };
306
+ }
307
+
308
+ /** A thread id is a positional: it comes out of `x pr review`, so it is never typed by hand. */
309
+ function threadIdOf(ctx: CommandContext, subcommand: string, example: string): string {
310
+ const id = ctx.args.positionals[0];
311
+ if (id === undefined) {
312
+ throw new MissingPositionalError({
313
+ command: `pr ${subcommand}`,
314
+ positional: 'thread-id',
315
+ example,
316
+ });
317
+ }
318
+ return id;
319
+ }
320
+
321
+ /**
322
+ * Resolving is a statement about the CONVERSATION. Nothing GitHub can be asked observes whether
323
+ * the finding is fixed, so the summary says what happened and refuses to imply the other thing —
324
+ * a command that reported "addressed" would be the framework asserting a fact it cannot check.
325
+ */
326
+ async function runResolve(ctx: CommandContext): Promise<CommandResult> {
327
+ const id = threadIdOf(ctx, 'resolve', 'x pr resolve PRRT_kwDOTkDHL86a6ivd --json');
328
+ const resolved = await resolveThread(ctx, id);
329
+ return {
330
+ ok: true,
331
+ command: 'pr',
332
+ summary: msg('cli.pr.resolved', { id: resolved }),
333
+ data: { threadId: resolved, isResolved: true },
334
+ };
335
+ }
336
+
337
+ async function runReply(ctx: CommandContext): Promise<CommandResult> {
338
+ const id = threadIdOf(
339
+ ctx,
340
+ 'reply',
341
+ 'x pr reply PRRT_kwDOTkDHL86a6ivd --body "fixed in 0f3a91c" --json',
342
+ );
343
+ const body = flagString(ctx.args, 'body');
344
+ if (body === undefined || body.trim() === '') {
345
+ throw new BadFlagError({
346
+ flag: 'body',
347
+ command: 'pr reply',
348
+ reason: 'a reply posts a comment, and an empty one says nothing to the reviewer',
349
+ fix: 'x pr reply PRRT_kwDOTkDHL86a6ivd --body "fixed in 0f3a91c" --json',
350
+ });
351
+ }
352
+ const url = await replyToThread(ctx, id, body);
353
+ return {
354
+ ok: true,
355
+ command: 'pr',
356
+ summary: msg('cli.pr.replied', { id, url }),
357
+ data: { threadId: id, url },
358
+ };
359
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { ActionDescriptor, AnyAction } from '@ultimat3/action';
8
8
  import { describeActions, getAction, jsonSchemaOf } from '@ultimat3/action';
9
+ import { nearestName } from '@ultimat3/core';
9
10
  import type { EntityDescription, RegistryEntry } from '@ultimat3/entity';
10
11
  import { describeEntities, getEntity } from '@ultimat3/entity';
11
12
  import type { AnyQuery, QueryDescriptor } from '@ultimat3/query';
@@ -17,7 +18,7 @@ import { DeclarationUnknownError, MissingPositionalError } from './errors';
17
18
  import { msg } from './messages';
18
19
  import type { CommandResult, Finding, JsonValue } from './output';
19
20
  import type { CommandSpec } from './parse';
20
- import { nearest } from './parse';
21
+
21
22
  import { renderTable } from './table';
22
23
 
23
24
  /**
@@ -154,7 +155,7 @@ function describeResult<D extends { readonly name: string }, Raw extends { descr
154
155
  const raw = kind.find(name);
155
156
  if (raw === undefined) {
156
157
  const known = kind.list().map((item) => item.name);
157
- const suggestion = nearest(name, known);
158
+ const suggestion = nearestName(name, known);
158
159
  throw new DeclarationUnknownError(
159
160
  suggestion === undefined
160
161
  ? { kind: kind.kind, singular: kind.singular, name, known }