|
62 | 62 | * dry-run mode of its own, which is the other half of why nothing measured this |
63 | 63 | * before. |
64 | 64 | * |
| 65 | + * ## Both ways a description reaches GitHub |
| 66 | + * |
| 67 | + * The vocabulary script has two modes: create-if-missing (default) and |
| 68 | + * `--reconcile`, which additionally sends colour and description to |
| 69 | + * `gh label edit`. `gh label edit` 422s over the cap exactly as `gh label create` |
| 70 | + * does, so the gate's charter — no description this script sends can 422 — |
| 71 | + * spans both. The self-test drives the script in BOTH modes and asserts that |
| 72 | + * every edit carries the same string as its create, which is what keeps the |
| 73 | + * single measured `-d` literal per label sufficient. Without those cases the |
| 74 | + * gate would still pass while covering only half of what the script sends. |
| 75 | + * |
65 | 76 | * Missing file or empty read is RED, never a pass — a gate that cannot find its |
66 | 77 | * input must fail, not skip. |
67 | 78 | */ |
68 | 79 |
|
69 | | -import { execFileSync } from 'node:child_process'; |
| 80 | +import { spawnSync } from 'node:child_process'; |
70 | 81 | import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; |
71 | 82 | import { tmpdir } from 'node:os'; |
72 | 83 | import { join } from 'node:path'; |
@@ -210,38 +221,72 @@ function readTarget() { |
210 | 221 | } |
211 | 222 |
|
212 | 223 | /** |
213 | | - * Run the real script with a fake `gh` on PATH and return every description it |
214 | | - * was actually invoked with. The fake records one argument per line and closes |
215 | | - * each invocation with a sentinel line; no argument in this file contains a |
216 | | - * newline, and no control bytes are written. |
| 224 | + * Run the real script with a fake `gh` on PATH and return every label call it |
| 225 | + * actually made, plus the script's exit status. The fake records one argument |
| 226 | + * per line and closes each invocation with a sentinel line; no argument in this |
| 227 | + * file contains a newline, and no control bytes are written. |
| 228 | + * |
| 229 | + * `args` are passed through to the script — which is how the `--reconcile` mode |
| 230 | + * is measured. `failEdit` makes the fake `gh` reject `label edit`, so the |
| 231 | + * self-test can assert that a failed reconciliation is LOUD: unlike creation, |
| 232 | + * whose `|| true` is mandatory for rerunnability, a swallowed edit failure would |
| 233 | + * report success while leaving the drift live. |
| 234 | + * |
| 235 | + * The script is run via `spawnSync`, not `execFileSync`, because two of the |
| 236 | + * behaviours under test are non-zero exits (an unknown flag, a failed edit) and |
| 237 | + * a throwing runner cannot observe an exit status it converts into an exception. |
217 | 238 | */ |
218 | | -export function dryRunDescriptions(scriptPath) { |
| 239 | +export function dryRunCalls(scriptPath, args = [], { failEdit = false } = {}) { |
219 | 240 | const dir = mkdtempSync(join(tmpdir(), 'pm-label-cap-')); |
220 | 241 | try { |
221 | 242 | const log = join(dir, 'calls.log'); |
222 | 243 | const fake = join(dir, 'gh'); |
223 | 244 | writeFileSync( |
224 | 245 | fake, |
225 | | - ['#!/usr/bin/env bash', 'for a in "$@"; do printf "%s\\n" "$a" >> "$FAKE_GH_LOG"; done', 'printf "<<<END>>>\\n" >> "$FAKE_GH_LOG"', 'exit 0', ''].join('\n'), |
| 246 | + [ |
| 247 | + '#!/usr/bin/env bash', |
| 248 | + 'for a in "$@"; do printf "%s\\n" "$a" >> "$FAKE_GH_LOG"; done', |
| 249 | + 'printf "<<<END>>>\\n" >> "$FAKE_GH_LOG"', |
| 250 | + 'if [ -n "${FAKE_GH_FAIL_EDIT:-}" ] && [ "${2:-}" = "edit" ]; then echo "fake gh: refusing" >&2; exit 1; fi', |
| 251 | + 'exit 0', |
| 252 | + '', |
| 253 | + ].join('\n'), |
226 | 254 | ); |
227 | 255 | chmodSync(fake, 0o755); |
228 | 256 | writeFileSync(log, ''); |
229 | | - execFileSync('bash', [scriptPath], { |
230 | | - env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, FAKE_GH_LOG: log }, |
231 | | - stdio: 'ignore', |
232 | | - }); |
233 | | - const out = []; |
| 257 | + const env = { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, FAKE_GH_LOG: log }; |
| 258 | + if (failEdit) env.FAKE_GH_FAIL_EDIT = '1'; |
| 259 | + const proc = spawnSync('bash', [scriptPath, ...args], { env, stdio: 'ignore' }); |
| 260 | + const calls = []; |
234 | 261 | for (const call of readFileSync(log, 'utf8').split('<<<END>>>\n')) { |
235 | | - const args = call.split('\n').filter((a) => a !== ''); |
236 | | - const i = args.indexOf('-d'); |
237 | | - if (args[0] === 'label' && args[1] === 'create' && i !== -1) out.push(args[i + 1]); |
| 262 | + const argv = call.split('\n').filter((a) => a !== ''); |
| 263 | + if (argv[0] !== 'label') continue; |
| 264 | + const flag = (...names) => { |
| 265 | + const i = argv.findIndex((a) => names.includes(a)); |
| 266 | + return i === -1 ? null : argv[i + 1]; |
| 267 | + }; |
| 268 | + calls.push({ |
| 269 | + verb: argv[1], |
| 270 | + name: argv[2], |
| 271 | + repo: flag('-R', '--repo'), |
| 272 | + color: flag('-c', '--color'), |
| 273 | + description: flag('-d', '--description'), |
| 274 | + argv, |
| 275 | + }); |
238 | 276 | } |
239 | | - return out; |
| 277 | + return { calls, status: proc.status }; |
240 | 278 | } finally { |
241 | 279 | rmSync(dir, { recursive: true, force: true }); |
242 | 280 | } |
243 | 281 | } |
244 | 282 |
|
| 283 | +/** Every description the script passes to `gh label create`. See dryRunCalls. */ |
| 284 | +export function dryRunDescriptions(scriptPath) { |
| 285 | + return dryRunCalls(scriptPath) |
| 286 | + .calls.filter((c) => c.verb === 'create' && c.description !== null) |
| 287 | + .map((c) => c.description); |
| 288 | +} |
| 289 | + |
245 | 290 | function run() { |
246 | 291 | const source = readTarget(); |
247 | 292 | const descriptions = parseDescriptions(source); |
@@ -357,6 +402,74 @@ function selfTest() { |
357 | 402 | // The floor: a parser that matches nothing must not read as clean. |
358 | 403 | t('an empty parse is below the floor', parseDescriptions('echo hi').length < MIN_DESCRIPTIONS, true); |
359 | 404 |
|
| 405 | + // ── The --reconcile mode ────────────────────────────────────────────────── |
| 406 | + // |
| 407 | + // This gate's charter is "no description the script sends to GitHub can 422". |
| 408 | + // Reconcile adds a SECOND way to send one (`gh label edit`), so without the |
| 409 | + // cases below the gate's coverage would silently shrink to the create path |
| 410 | + // while still reading as a full pass — the parser-matches-nothing failure in |
| 411 | + // a new place. The cases assert the two properties that keep the cap honest: |
| 412 | + // every edit carries the same string as its create, and nothing else is sent. |
| 413 | + const script = join(REPO_ROOT, TARGET); |
| 414 | + // The verb is deliberately NOT part of the signature: these cases compare a |
| 415 | + // create against its edit, and equality is the whole assertion. |
| 416 | + const sig = (c) => JSON.stringify([c.name, c.repo, c.color, c.description]); |
| 417 | + const verbs = (calls, v) => calls.filter((c) => c.verb === v); |
| 418 | + |
| 419 | + const plain = dryRunCalls(script); |
| 420 | + const reconciled = dryRunCalls(script, ['--reconcile']); |
| 421 | + |
| 422 | + t('the default run issues no label edit at all', verbs(plain.calls, 'edit').length, 0); |
| 423 | + t('…and exits 0', plain.status, 0); |
| 424 | + t('reconcile exits 0 when every edit succeeds', reconciled.status, 0); |
| 425 | + t( |
| 426 | + 'reconcile creates exactly what the default run creates', |
| 427 | + verbs(reconciled.calls, 'create').map(sig), |
| 428 | + verbs(plain.calls, 'create').map(sig), |
| 429 | + ); |
| 430 | + t( |
| 431 | + 'reconcile edits exactly the labels it creates — same name, repo, colour and description', |
| 432 | + verbs(reconciled.calls, 'edit').map(sig), |
| 433 | + verbs(reconciled.calls, 'create').map(sig), |
| 434 | + ); |
| 435 | + t( |
| 436 | + 'so every description reconcile SENDS is one this gate already measured', |
| 437 | + verbs(reconciled.calls, 'edit').filter((c) => !real.some((r) => templateMatches(r.raw, c.description))), |
| 438 | + [], |
| 439 | + ); |
| 440 | + t( |
| 441 | + '…and none of them would 422', |
| 442 | + verbs(reconciled.calls, 'edit').filter((c) => charLength(c.description) > MAX_DESCRIPTION_CHARS), |
| 443 | + [], |
| 444 | + ); |
| 445 | + t( |
| 446 | + 'reconcile issues no verb other than create and edit — it never deletes', |
| 447 | + [...new Set(reconciled.calls.map((c) => c.verb))].filter((v) => v !== 'create' && v !== 'edit'), |
| 448 | + [], |
| 449 | + ); |
| 450 | + t( |
| 451 | + 'reconcile never renames: no --name/-n reaches gh', |
| 452 | + reconciled.calls.filter((c) => c.argv.includes('--name') || c.argv.includes('-n')).map((c) => c.name), |
| 453 | + [], |
| 454 | + ); |
| 455 | + t( |
| 456 | + 'reconcile is idempotent — a second run issues the identical call sequence', |
| 457 | + dryRunCalls(script, ['--reconcile']).calls.map(sig), |
| 458 | + reconciled.calls.map(sig), |
| 459 | + ); |
| 460 | + |
| 461 | + // A mistyped flag must not fall through to a create-only run that prints the |
| 462 | + // ordinary success line — the operator would read a reconciliation that never |
| 463 | + // happened as done. |
| 464 | + const mistyped = dryRunCalls(script, ['--reconsile']); |
| 465 | + t('an unknown flag exits non-zero', mistyped.status, 2); |
| 466 | + t('…and issues no gh calls at all', mistyped.calls.length, 0); |
| 467 | + |
| 468 | + // The asymmetry with creation: a failed edit means the drift is still live. |
| 469 | + const brokenEdit = dryRunCalls(script, ['--reconcile'], { failEdit: true }); |
| 470 | + t('a failing gh label edit makes the script exit non-zero', brokenEdit.status !== 0, true); |
| 471 | + t('…while the same failure in the default mode cannot arise (no edits)', verbs(dryRunCalls(script, [], { failEdit: true }).calls, 'edit').length, 0); |
| 472 | + |
360 | 473 | if (failed) { |
361 | 474 | console.error(`\n❌ check-label-desc-cap --self-test: ${failed} case(s) failed`); |
362 | 475 | process.exit(1); |
|
0 commit comments