@dzhechkov/harness-core 0.4.4 → 0.5.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 (97) hide show
  1. package/.dz-manifest.json +283 -103
  2. package/LICENSE +21 -0
  3. package/README.md +73 -5
  4. package/dist/agents-policy.d.ts +67 -0
  5. package/dist/agents-policy.d.ts.map +1 -0
  6. package/dist/agents-policy.js +258 -0
  7. package/dist/agents-policy.js.map +1 -0
  8. package/dist/codex-hooks-assets.d.ts +47 -0
  9. package/dist/codex-hooks-assets.d.ts.map +1 -0
  10. package/dist/codex-hooks-assets.js +287 -0
  11. package/dist/codex-hooks-assets.js.map +1 -0
  12. package/dist/codex-hooks-verify.d.ts +74 -0
  13. package/dist/codex-hooks-verify.d.ts.map +1 -0
  14. package/dist/codex-hooks-verify.js +140 -0
  15. package/dist/codex-hooks-verify.js.map +1 -0
  16. package/dist/codex-hooks.d.ts +258 -0
  17. package/dist/codex-hooks.d.ts.map +1 -0
  18. package/dist/codex-hooks.js +391 -0
  19. package/dist/codex-hooks.js.map +1 -0
  20. package/dist/discrimination-gate.d.ts +88 -15
  21. package/dist/discrimination-gate.d.ts.map +1 -1
  22. package/dist/discrimination-gate.js +343 -51
  23. package/dist/discrimination-gate.js.map +1 -1
  24. package/dist/feature-adr-checkpoints.d.ts +22 -0
  25. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +42 -0
  27. package/dist/feature-adr-checkpoints.js.map +1 -1
  28. package/dist/feature-adr-routing.d.ts +196 -5
  29. package/dist/feature-adr-routing.d.ts.map +1 -1
  30. package/dist/feature-adr-routing.js +538 -54
  31. package/dist/feature-adr-routing.js.map +1 -1
  32. package/dist/guard.d.ts +13 -0
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +25 -1
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/loop-blobs.generated.js +2 -2
  41. package/dist/loop-blobs.generated.js.map +1 -1
  42. package/dist/managed-hooks.d.ts +76 -0
  43. package/dist/managed-hooks.d.ts.map +1 -0
  44. package/dist/managed-hooks.js +89 -0
  45. package/dist/managed-hooks.js.map +1 -0
  46. package/dist/mutation-gate.d.ts +14 -0
  47. package/dist/mutation-gate.d.ts.map +1 -1
  48. package/dist/mutation-gate.js +25 -2
  49. package/dist/mutation-gate.js.map +1 -1
  50. package/dist/operations.d.ts +153 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +560 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/parity.d.ts +38 -1
  55. package/dist/parity.d.ts.map +1 -1
  56. package/dist/parity.js +78 -5
  57. package/dist/parity.js.map +1 -1
  58. package/dist/recall-usage.d.ts +53 -0
  59. package/dist/recall-usage.d.ts.map +1 -1
  60. package/dist/recall-usage.js +125 -2
  61. package/dist/recall-usage.js.map +1 -1
  62. package/dist/setup.d.ts.map +1 -1
  63. package/dist/setup.js +14 -26
  64. package/dist/setup.js.map +1 -1
  65. package/dist/shell-veto-policy.d.ts +53 -0
  66. package/dist/shell-veto-policy.d.ts.map +1 -0
  67. package/dist/shell-veto-policy.js +103 -0
  68. package/dist/shell-veto-policy.js.map +1 -0
  69. package/dist/skills.d.ts +86 -1
  70. package/dist/skills.d.ts.map +1 -1
  71. package/dist/skills.js +116 -1
  72. package/dist/skills.js.map +1 -1
  73. package/dist/targets.d.ts +75 -0
  74. package/dist/targets.d.ts.map +1 -1
  75. package/dist/targets.js +160 -0
  76. package/dist/targets.js.map +1 -1
  77. package/package.json +20 -19
  78. package/sbom.json +552 -102
  79. package/src/agents-policy.ts +338 -0
  80. package/src/codex-hooks-assets.ts +291 -0
  81. package/src/codex-hooks-verify.ts +184 -0
  82. package/src/codex-hooks.ts +571 -0
  83. package/src/discrimination-gate.ts +456 -58
  84. package/src/feature-adr-checkpoints.ts +38 -0
  85. package/src/feature-adr-routing.ts +642 -75
  86. package/src/guard.ts +36 -1
  87. package/src/index.ts +118 -2
  88. package/src/loop-blobs.generated.ts +2 -2
  89. package/src/managed-hooks.ts +129 -0
  90. package/src/mutation-gate.ts +24 -2
  91. package/src/operations.ts +719 -28
  92. package/src/parity.ts +120 -6
  93. package/src/recall-usage.ts +184 -1
  94. package/src/setup.ts +26 -27
  95. package/src/shell-veto-policy.ts +119 -0
  96. package/src/skills.ts +174 -1
  97. package/src/targets.ts +189 -0
package/src/skills.ts CHANGED
@@ -75,7 +75,17 @@ export function discoverSkillIds(skillsDir: string): string[] {
75
75
  .sort();
76
76
  }
77
77
 
78
- /** Discover every skill in `skillsDir`, returning id + description. */
78
+ /**
79
+ * Discover every skill in `skillsDir`, returning id + description.
80
+ *
81
+ * **Throws on the first unloadable skill — deliberately, and permanently.** This is a
82
+ * published export; silently turning it into a skip-and-collect function would downgrade
83
+ * every unknown third-party consumer from fail-closed to fail-silent without their
84
+ * consent (an incomplete catalogue reported as complete). Callers that want a partial
85
+ * listing ask for one by name: {@link listSkillsDetailed}. A pinned regression test
86
+ * asserts this function still throws, so a future "helpful" refactor cannot quietly
87
+ * erase the strict variant. (feature dz-cli-defects, ADR-001 as amended by AM-6.)
88
+ */
79
89
  export function listSkills(skillsDir: string): SkillSummary[] {
80
90
  return discoverSkillIds(skillsDir).map((id) => {
81
91
  const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
@@ -84,6 +94,169 @@ export function listSkills(skillsDir: string): SkillSummary[] {
84
94
  });
85
95
  }
86
96
 
97
+ // ---------------------------------------------------------------------------
98
+ // Skip-and-collect (feature dz-cli-defects, D1)
99
+ //
100
+ // The parser (`@dzhechkov/core/src/skill-document.ts`) is handed only TEXT, so its
101
+ // message can never carry a path. `describeSkillLoadFailure` is the ONE place that
102
+ // turns a pathless throw into a named failure — every consumer calls it, so "named,
103
+ // never anonymous" has one implementation and one test.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** How much of the offending file's first line is echoed back to the user. */
107
+ export const SKILL_FAILURE_FIRST_LINE_MAX = 100;
108
+
109
+ /** One skill directory that could not be loaded. Named, so a log is actionable. */
110
+ export interface SkillLoadFailure {
111
+ /** The skill id (its directory name), e.g. `bto`. */
112
+ readonly id: string;
113
+ /** ABSOLUTE path to the offending `SKILL.md`. */
114
+ readonly path: string;
115
+ /** The caught error's message, verbatim — the classifier stays out of the loader. */
116
+ readonly reason: string;
117
+ /**
118
+ * First line of the source text, trimmed and capped at
119
+ * {@link SKILL_FAILURE_FIRST_LINE_MAX}; `''` when the file is unreadable. Echoing it
120
+ * is what turns the message into a FIX — the user sees the H1 and knows to add the
121
+ * frontmatter fence.
122
+ */
123
+ readonly firstLine: string;
124
+ }
125
+
126
+ /** A listing that separates what parsed from what did not. */
127
+ export interface SkillListing {
128
+ /** Sorted by id — same order, same shape as {@link listSkills} produces today. */
129
+ readonly skills: readonly SkillSummary[];
130
+ /** Sorted by id. Empty when every skill loaded. */
131
+ readonly failures: readonly SkillLoadFailure[];
132
+ }
133
+
134
+ /**
135
+ * Attribute any skill-load throw to a file. Shared by every consumer
136
+ * (`listSkillsDetailed`, `runInit`, `runInitSingleFileMd`, `runSync`).
137
+ *
138
+ * Best-effort on the first line: an unreadable file yields `''` rather than a second
139
+ * throw — this helper runs on an error path and must never become one.
140
+ */
141
+ export function describeSkillLoadFailure(
142
+ skillsDir: string,
143
+ id: string,
144
+ error: unknown,
145
+ ): SkillLoadFailure {
146
+ const path = join(skillsDir, id, 'SKILL.md');
147
+ let firstLine = '';
148
+ try {
149
+ const raw = readFileSync(path, 'utf-8');
150
+ const line = (raw.split('\n', 1)[0] ?? '').replace(/\r$/, '').trim();
151
+ firstLine =
152
+ line.length > SKILL_FAILURE_FIRST_LINE_MAX
153
+ ? `${line.slice(0, SKILL_FAILURE_FIRST_LINE_MAX)}…`
154
+ : line;
155
+ } catch {
156
+ firstLine = '';
157
+ }
158
+ return {
159
+ id,
160
+ path,
161
+ reason: error instanceof Error ? error.message : String(error),
162
+ firstLine,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Discover every skill in `skillsDir`, separating the ones that parsed from the ones
168
+ * that did not. One broken `SKILL.md` never hides the rest.
169
+ *
170
+ * Catch policy: **every** error per id, not only `SkillDocumentError` — an `EACCES`, a
171
+ * YAML syntax error and a Zod schema rejection are all equally "this one skill is
172
+ * unusable". The `reason` is the caught message verbatim.
173
+ */
174
+ export function listSkillsDetailed(skillsDir: string): SkillListing {
175
+ const skills: SkillSummary[] = [];
176
+ const failures: SkillLoadFailure[] = [];
177
+ for (const id of discoverSkillIds(skillsDir)) {
178
+ try {
179
+ const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
180
+ const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
181
+ skills.push({ id, description: frontmatter.description });
182
+ } catch (error) {
183
+ failures.push(describeSkillLoadFailure(skillsDir, id, error));
184
+ }
185
+ }
186
+ return { skills, failures };
187
+ }
188
+
189
+ /**
190
+ * Render a `SkillLoadFailure[]` as the diagnostic block a CLI writes to **stderr**.
191
+ *
192
+ * One helper, two rendering modes, chosen by the CALLER — never by the helper sniffing
193
+ * the path. `dz list` / `dz sync` print absolute paths (the user can act on those);
194
+ * `dz install` passes `relativeTo` = the downloaded package root, because a
195
+ * `node_modules/**` absolute path is not something the user can act on.
196
+ *
197
+ * Returns `[]` for an empty input, so callers can splice it unconditionally.
198
+ */
199
+ export function formatSkillLoadFailures(
200
+ failures: readonly SkillLoadFailure[],
201
+ opts: { readonly relativeTo?: string } = {},
202
+ ): string[] {
203
+ if (failures.length === 0) return [];
204
+ const lines = [`⚠ ${failures.length} skill(s) skipped (unparseable SKILL.md):`];
205
+ for (const failure of failures) {
206
+ const shown =
207
+ opts.relativeTo !== undefined
208
+ ? relative(opts.relativeTo, failure.path).split('\\').join('/')
209
+ : failure.path;
210
+ lines.push(` ${shown}`);
211
+ lines.push(` ${failure.reason}`);
212
+ if (failure.firstLine !== '') lines.push(` (line 1: ${JSON.stringify(failure.firstLine)})`);
213
+ }
214
+ return lines;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // The SECOND failure kind (fix round 1, QE F4)
219
+ //
220
+ // A load failure and an APPLY failure are different accusations. The first says
221
+ // "this SKILL.md is broken"; the second says "this skill is fine, the target could
222
+ // not be written". Funnelling both through `describeSkillLoadFailure` produced a
223
+ // message that contradicted its own body — an `EEXIST: mkdir …/.claude/skills/alpha`
224
+ // rendered under the header "unparseable SKILL.md", quoting `line 1: "---"` (a VALID
225
+ // fence) as its evidence and naming the innocent SOURCE file (MEASURED 2026-08-18).
226
+ // Driver D2 ("every failure must name its subject") is worse than unmet when the
227
+ // subject named is the wrong artifact.
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * One skill that LOADED cleanly but could not be compiled for, or written to, the
232
+ * target. The subject is the TARGET, never the source `SKILL.md`.
233
+ */
234
+ export interface SkillApplyFailure {
235
+ /** The skill id (its directory name). */
236
+ readonly id: string;
237
+ /** The caught error's message, verbatim. */
238
+ readonly reason: string;
239
+ }
240
+
241
+ /**
242
+ * Render a `SkillApplyFailure[]` as the diagnostic block a CLI writes to **stderr**.
243
+ *
244
+ * Deliberately a DIFFERENT header from {@link formatSkillLoadFailures}: the two kinds
245
+ * point the user at two different files, and a shared header is what let a write
246
+ * failure masquerade as a parse failure.
247
+ *
248
+ * Returns `[]` for an empty input, so callers can splice it unconditionally.
249
+ */
250
+ export function formatSkillApplyFailures(failures: readonly SkillApplyFailure[]): string[] {
251
+ if (failures.length === 0) return [];
252
+ const lines = [`✗ ${failures.length} skill(s) failed to install (compile/write error):`];
253
+ for (const failure of failures) {
254
+ lines.push(` ${failure.id}`);
255
+ lines.push(` ${failure.reason}`);
256
+ }
257
+ return lines;
258
+ }
259
+
87
260
  /** Get detailed info about a single skill without loading all assets. */
88
261
  export function getSkillInfo(skillsDir: string, id: string): SkillInfo | undefined {
89
262
  const skillDir = join(skillsDir, id);
package/src/targets.ts CHANGED
@@ -48,3 +48,192 @@ export const TARGET_NAMES = Object.keys(TARGETS) as TargetName[];
48
48
  export function isTargetName(value: string): value is TargetName {
49
49
  return Object.prototype.hasOwnProperty.call(TARGETS, value);
50
50
  }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // `--target` resolution — alias table + did-you-mean (feature dz-cli-defects, D3)
54
+ //
55
+ // `isTargetName`, `TARGETS` and `TARGET_NAMES` above are UNCHANGED: `boundaries.json`
56
+ // names `isTargetName` as the scanned validation boundary for `--target`, and every
57
+ // resolution below ends in exactly that guard, so the security boundary is preserved
58
+ // rather than relocated (ADR-002 / architecture §3.1).
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** `TARGET_NAMES` sorted alphabetically, for display in error messages. */
62
+ export const TARGET_NAMES_SORTED: readonly TargetName[] = [...TARGET_NAMES].sort();
63
+
64
+ /**
65
+ * Semantic `--target` aliases — DATA, not branching logic. Adding a row is one line
66
+ * and zero control flow (ADR-002 §Rationale D5).
67
+ *
68
+ * Purely typographic variants (`Claude_Code`, `claudecode`, `agents.md`) are handled by
69
+ * normalisation, not by rows; the rows below carry only meanings normalisation cannot
70
+ * derive (`claude` ≠ `claude-code` by any string rule — it is an owner decision).
71
+ */
72
+ export const TARGET_ALIASES: Readonly<Record<string, TargetName>> = {
73
+ claude: 'claude-code',
74
+ cc: 'claude-code',
75
+ agents: 'agents-md',
76
+ gpt: 'codex',
77
+ openai: 'codex',
78
+ };
79
+
80
+ /**
81
+ * Rows deleted in fix round 1 (QE F7) because they were UNREACHABLE, not because their
82
+ * inputs stopped working: `claude_code`, `claudecode`, `agentsmd` and `agents.md` all
83
+ * normalise onto a canonical name, and precedence step 2 (normalised canonical) runs
84
+ * BEFORE step 3 (this table). Every one of them still resolves — through normalisation,
85
+ * which is where the README already says typographic variants are handled.
86
+ *
87
+ * They were invisible dead data because the table test iterated every row asserting
88
+ * `via === 'alias'`, which is true on both paths — a PRESENCE assertion where a
89
+ * REACHABILITY one was intended. `target-resolution.test.ts` now removes each row from a
90
+ * COPY of the table and asserts the resolution changes, so a future dead row is red.
91
+ */
92
+
93
+
94
+ /** The outcome of {@link resolveTargetName}. Total: every string maps to one of these. */
95
+ export type TargetResolution =
96
+ | { readonly kind: 'ok'; readonly target: TargetName; readonly via: 'canonical' | 'alias' }
97
+ | { readonly kind: 'unknown'; readonly input: string; readonly suggestion: TargetName | null };
98
+
99
+ /** Levenshtein suggestions are only offered at or below this edit distance. */
100
+ export const TARGET_SUGGESTION_MAX_DISTANCE = 3;
101
+
102
+ /**
103
+ * Normalise a `--target` token for matching: byte-level lowercase (never
104
+ * `toLocaleLowerCase` — behaviour must not vary with the host locale), trimmed, with
105
+ * every separator dropped so `Claude_Code`, `claude-code` and `claudecode` collapse.
106
+ *
107
+ * Exported so the alias-REACHABILITY test can ask the production normaliser whether a
108
+ * proposed alias row is already carried by precedence step 2, instead of keeping a
109
+ * second copy of this rule in the test file (fix round 1, QE F7).
110
+ */
111
+ export function normalizeTargetToken(value: string): string {
112
+ return value.trim().toLowerCase().replace(/[-_.\s]/g, '');
113
+ }
114
+
115
+ /** Iterative two-row Levenshtein. No dependency: harness-core ships no fuzzy matcher. */
116
+ function levenshtein(a: string, b: string): number {
117
+ if (a === b) return 0;
118
+ if (a.length === 0) return b.length;
119
+ if (b.length === 0) return a.length;
120
+ let prev: number[] = Array.from({ length: b.length + 1 }, (_, i) => i);
121
+ let curr: number[] = new Array<number>(b.length + 1).fill(0);
122
+ for (let i = 1; i <= a.length; i += 1) {
123
+ curr[0] = i;
124
+ for (let j = 1; j <= b.length; j += 1) {
125
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
126
+ curr[j] = Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
127
+ }
128
+ const swap = prev;
129
+ prev = curr;
130
+ curr = swap;
131
+ }
132
+ return prev[b.length] ?? 0;
133
+ }
134
+
135
+ /**
136
+ * Suggest a canonical target for an input that did not resolve.
137
+ *
138
+ * Two legs, in order, and each closes a case the other cannot:
139
+ *
140
+ * - **unique normalised prefix** — carries `clau → claude-code`, which is edit distance 5
141
+ * and therefore unreachable by any sane Levenshtein threshold. An input that prefixes
142
+ * TWO OR MORE canonical names is AMBIGUOUS and terminates with **no** suggestion
143
+ * (`co` → `codex`/`copilot`): guessing between two live targets is worse than a
144
+ * round-trip, and the fall-through would otherwise hand `co` to `codex` on a distance
145
+ * of 3 — a confident answer to a question the user has not yet decided.
146
+ * - **Levenshtein ≤ 3, strictly better than the runner-up** — carries the typo case
147
+ * `clade-code → claude-code`. A tie yields `null` for the same reason.
148
+ */
149
+ function suggestTargetName(normalized: string): TargetName | null {
150
+ if (normalized.length === 0) return null;
151
+
152
+ const prefixHits = TARGET_NAMES_SORTED.filter((name) =>
153
+ normalizeTargetToken(name).startsWith(normalized),
154
+ );
155
+ if (prefixHits.length === 1) return prefixHits[0] ?? null;
156
+ // Ambiguous prefix is TERMINAL, not a fall-through (see doc comment).
157
+ if (prefixHits.length > 1) return null;
158
+
159
+ let best: TargetName | null = null;
160
+ let bestDistance = Number.POSITIVE_INFINITY;
161
+ let runnerUpDistance = Number.POSITIVE_INFINITY;
162
+ for (const name of TARGET_NAMES_SORTED) {
163
+ const distance = levenshtein(normalized, normalizeTargetToken(name));
164
+ if (distance < bestDistance) {
165
+ runnerUpDistance = bestDistance;
166
+ bestDistance = distance;
167
+ best = name;
168
+ } else if (distance < runnerUpDistance) {
169
+ runnerUpDistance = distance;
170
+ }
171
+ }
172
+ if (best === null) return null;
173
+ if (bestDistance > TARGET_SUGGESTION_MAX_DISTANCE) return null;
174
+ // Strictly better than the runner-up — a tie is not a suggestion.
175
+ if (bestDistance === runnerUpDistance) return null;
176
+ return best;
177
+ }
178
+
179
+ /**
180
+ * Resolve a user-supplied `--target` value to a canonical {@link TargetName}.
181
+ *
182
+ * Total, pure, no I/O. Precedence (fixed and tested):
183
+ *
184
+ * 1. exact canonical hit → `{kind:'ok', via:'canonical'}` (`isTargetName` semantics);
185
+ * 2. normalised canonical hit → `{kind:'ok', via:'alias'}` (`Claude_Code`, `agentsmd`);
186
+ * 3. explicit {@link TARGET_ALIASES} row → `{kind:'ok', via:'alias'}`;
187
+ * 4. unique normalised prefix → `{kind:'unknown', suggestion}`;
188
+ * 5. Levenshtein ≤ 3 strictly better than the runner-up → `{kind:'unknown', suggestion}`;
189
+ * 6. otherwise → `{kind:'unknown', suggestion:null}`.
190
+ *
191
+ * **Aliases ACCEPT; prefix and Levenshtein only SUGGEST.** An alias row is an owner
192
+ * decision recorded in data; a fuzzy match is a guess, and silently installing to the
193
+ * wrong target on a guess is worse than one round-trip.
194
+ */
195
+ export function resolveTargetName(value: string): TargetResolution {
196
+ if (isTargetName(value)) return { kind: 'ok', target: value, via: 'canonical' };
197
+
198
+ const normalized = normalizeTargetToken(value);
199
+ for (const name of TARGET_NAMES) {
200
+ if (normalizeTargetToken(name) === normalized) {
201
+ return { kind: 'ok', target: name, via: 'alias' };
202
+ }
203
+ }
204
+
205
+ const aliased =
206
+ Object.prototype.hasOwnProperty.call(TARGET_ALIASES, normalized)
207
+ ? TARGET_ALIASES[normalized]
208
+ : Object.prototype.hasOwnProperty.call(TARGET_ALIASES, value.trim().toLowerCase())
209
+ ? TARGET_ALIASES[value.trim().toLowerCase()]
210
+ : undefined;
211
+ if (aliased !== undefined) return { kind: 'ok', target: aliased, via: 'alias' };
212
+
213
+ return { kind: 'unknown', input: value, suggestion: suggestTargetName(normalized) };
214
+ }
215
+
216
+ /**
217
+ * Render the two-line failure for an unresolvable `--target`.
218
+ *
219
+ * Line 2 keeps the literal substring `--target must be one of:` — three shipped
220
+ * assertions (`test/cli.test.ts`) pin it, and keeping the shape additive is what makes
221
+ * the D3 change prove itself with NEW tests instead of rewriting old ones. The values
222
+ * are {@link TARGET_NAMES_SORTED} (alphabetical), which the pre-change message was not.
223
+ */
224
+ export function formatTargetProblem(
225
+ command: string,
226
+ resolution: Extract<TargetResolution, { kind: 'unknown' }>,
227
+ ): readonly string[] {
228
+ const suggestion =
229
+ resolution.suggestion !== null ? ` — did you mean ${JSON.stringify(resolution.suggestion)}?` : '';
230
+ return [
231
+ `${command}: unknown --target ${JSON.stringify(resolution.input)}${suggestion}`,
232
+ ` --target must be one of: ${TARGET_NAMES_SORTED.join(', ')}`,
233
+ ];
234
+ }
235
+
236
+ /** The one-line diagnostic emitted (on stderr) when an alias was accepted. */
237
+ export function formatTargetAliasNote(command: string, input: string, target: TargetName): string {
238
+ return `${command}: --target ${JSON.stringify(input)} → ${target} (alias)`;
239
+ }