redlinegate 0.0.1 → 0.0.3

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 (86) hide show
  1. package/README.md +23 -7
  2. package/dist/bin/redline.js +338 -43
  3. package/dist/bin/redline.js.map +1 -1
  4. package/dist/commands/init.js +353 -34
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/remove.js +43 -1
  7. package/dist/commands/remove.js.map +1 -1
  8. package/dist/commands/status.js +94 -0
  9. package/dist/commands/status.js.map +1 -0
  10. package/dist/commands/verify.js +82 -2
  11. package/dist/commands/verify.js.map +1 -1
  12. package/dist/config/redline-json.js +63 -2
  13. package/dist/config/redline-json.js.map +1 -1
  14. package/dist/core/git.js +50 -3
  15. package/dist/core/git.js.map +1 -1
  16. package/dist/core/version.js +6 -0
  17. package/dist/core/version.js.map +1 -1
  18. package/dist/detect/existing.js +151 -0
  19. package/dist/detect/existing.js.map +1 -0
  20. package/dist/detect/setup.js +200 -0
  21. package/dist/detect/setup.js.map +1 -0
  22. package/dist/detect/stack.js +56 -17
  23. package/dist/detect/stack.js.map +1 -1
  24. package/dist/exempt/parse.js +8 -1
  25. package/dist/exempt/parse.js.map +1 -1
  26. package/dist/metrics/options.js +0 -9
  27. package/dist/metrics/options.js.map +1 -1
  28. package/dist/platforms/azure/install.js +9 -9
  29. package/dist/platforms/azure/install.js.map +1 -1
  30. package/dist/platforms/azure/verify.js +10 -2
  31. package/dist/platforms/azure/verify.js.map +1 -1
  32. package/dist/platforms/github/install.js +192 -26
  33. package/dist/platforms/github/install.js.map +1 -1
  34. package/dist/platforms/github/preflight.js +63 -0
  35. package/dist/platforms/github/preflight.js.map +1 -0
  36. package/dist/platforms/github/vendor.js +81 -0
  37. package/dist/platforms/github/vendor.js.map +1 -0
  38. package/dist/platforms/github/verify.js +79 -7
  39. package/dist/platforms/github/verify.js.map +1 -1
  40. package/dist/platforms/types.js +8 -0
  41. package/dist/platforms/types.js.map +1 -1
  42. package/dist/policy/diff.js +26 -9
  43. package/dist/policy/diff.js.map +1 -1
  44. package/dist/render/contexts.js +39 -0
  45. package/dist/render/contexts.js.map +1 -0
  46. package/dist/render/profile.js +44 -10
  47. package/dist/render/profile.js.map +1 -1
  48. package/dist/render/standards.js +10 -1
  49. package/dist/render/standards.js.map +1 -1
  50. package/dist/render/vendors.js +14 -53
  51. package/dist/render/vendors.js.map +1 -1
  52. package/dist/rules/catalogue.js +127 -0
  53. package/dist/rules/catalogue.js.map +1 -0
  54. package/dist/ui/facts.js +111 -0
  55. package/dist/ui/facts.js.map +1 -0
  56. package/dist/ui/prompt.js +330 -0
  57. package/dist/ui/prompt.js.map +1 -0
  58. package/dist/ui/report.js +214 -0
  59. package/dist/ui/report.js.map +1 -0
  60. package/dist/ui/tty.js +576 -0
  61. package/dist/ui/tty.js.map +1 -0
  62. package/dist/ui/wizard.js +293 -0
  63. package/dist/ui/wizard.js.map +1 -0
  64. package/dist/verify/remote.js +33 -0
  65. package/dist/verify/remote.js.map +1 -1
  66. package/package.json +13 -1
  67. package/platforms/azure/gate-template-github.yml +152 -0
  68. package/platforms/azure/gate-template.yml +59 -6
  69. package/scripts/check-pins.mjs +84 -1
  70. package/scripts/fetch-stars.mjs +92 -0
  71. package/scripts/lib/rules.d.mts +18 -0
  72. package/scripts/publish-local.mjs +183 -0
  73. package/standards/contexts/speckit.md +22 -0
  74. package/standards/contexts/tmf.md +25 -0
  75. package/standards/manifest.json +78 -7
  76. package/standards/stacks/angular.md +57 -0
  77. package/standards/stacks/dom.md +61 -0
  78. package/standards/stacks/svelte.md +45 -0
  79. package/standards/stacks/vue.md +54 -0
  80. package/templates/redline.yml +9 -9
  81. package/workflows/dashboard.yml +1 -1
  82. package/workflows/redline-collect.yml +1 -1
  83. package/workflows/redline-gate.yml +38 -7
  84. package/workflows/seed-canary.yml +2 -2
  85. package/workflows/weekly-digest.yml +1 -1
  86. package/scripts/measure-context.mjs +0 -101
@@ -1,24 +1,28 @@
1
1
  import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { isRedlineError, RedlineError } from "../core/errors.js";
4
- import { CLI_VERSION } from "../core/version.js";
4
+ import { CLI_VERSION, UNPUBLISHED_VERSION } from "../core/version.js";
5
+ import { VENDORED_GATE_PATH } from "../platforms/github/vendor.js";
5
6
  import { canPromote } from "../enforce/ladder.js";
6
- import { CAPABILITY_KEYS, CONFIG_FILE, MENU_KEYS, deselectedCapabilities, labelsCarriedByGate, readConfig, writeConfig, } from "../config/redline-json.js";
7
+ import { CAPABILITY_KEYS, MENU_DEFAULTS, CONFIG_FILE, MENU_KEYS, deselectedCapabilities, labelsCarriedByGate, readConfig, writeConfig, } from "../config/redline-json.js";
7
8
  import { proposeProfile } from "../detect/stack.js";
8
9
  import { scanRepo } from "../detect/scan.js";
9
10
  import { loadManifest } from "../render/manifest.js";
10
11
  import { resolveProfile } from "../render/profile.js";
11
12
  import { render } from "../render/standards.js";
12
13
  import { renderCommands, COMMAND_HOSTS } from "../render/commands.js";
14
+ import { CONTEXTS, detectSpecKit } from "../render/contexts.js";
15
+ import { surveyRepo, TOOL_PROBES } from "../detect/existing.js";
16
+ import { applySetup, offerable } from "../detect/setup.js";
13
17
  import { LOCAL_RULES_FILE } from "../render/vendors.js";
14
18
  import { isPending } from "../platforms/types.js";
15
- export const DEFAULT_MENU = {
16
- blockingGate: false,
17
- adrForLargeDiffs: true,
18
- accessibility: true,
19
- speckit: false,
20
- sensitivePathReviewers: true,
21
- };
19
+ // What a repository gets when it says nothing. Every default here has to be
20
+ // safe on a repository nobody has looked at, because that is the one the
21
+ // command is usually run on.
22
+ // Re-exported from the config module, which owns them so the parser can fill a
23
+ // key a repository was onboarded before. See MENU_DEFAULTS there for what each
24
+ // default is and why.
25
+ export const DEFAULT_MENU = MENU_DEFAULTS;
22
26
  // Named once so the label FLOOR_GATE soft-fails on and the label
23
27
  // openPullRequest applies below share one literal instead of two that could
24
28
  // drift apart, and so web/components/journey.tsx's hardcoded SYNC_LABEL can
@@ -63,11 +67,29 @@ export const SENSITIVE_PATHS = [
63
67
  '/terraform/',
64
68
  'Dockerfile',
65
69
  ];
70
+ // The team that owns the paths Redline seeds into CODEOWNERS.
71
+ //
72
+ // A default, never a fact. GitHub silently ignores an owner it cannot resolve —
73
+ // no error on push, no warning in the UI — so a CODEOWNERS naming a team that
74
+ // does not exist reports as installed and enforces nothing, which is the worst
75
+ // of the three possible outcomes. This was hardcoded, which meant every
76
+ // organisation but the one it was written for got exactly that. `--review-owners`
77
+ // states the real owners; absent, the capability still defaults to this name and
78
+ // the report says plainly that it is a guess.
66
79
  export const OWNING_TEAM = 'platform-engineering';
67
80
  // Accessibility rules only exist for the stacks that render a user interface,
68
81
  // so recording `accessibility: true` on a Terraform or Go repository files a
69
82
  // commitment against rules that will never be rendered there.
70
- const ACCESSIBILITY_STACKS = ['react', 'react-native', 'kotlin', 'swift'];
83
+ const ACCESSIBILITY_STACKS = [
84
+ 'react',
85
+ 'react-native',
86
+ 'angular',
87
+ 'vue',
88
+ 'svelte',
89
+ 'dom',
90
+ 'kotlin',
91
+ 'swift',
92
+ ];
71
93
  // The 2.1 artifact that identifies a repository onboarded by the previous
72
94
  // generation. It is NOT in the removal list below: v3's GitHub adapter writes
73
95
  // the same path, so removing it would delete the gate this run just installed
@@ -88,8 +110,33 @@ const LEGACY_SCRIPT = /^redline-.*\.sh$/;
88
110
  // The brownfield rule applies to files as much as to host objects: what carries
89
111
  // no Redline marker belongs to a human and is never deleted.
90
112
  const REDLINE_OWNED = /(?:managed|generated|installed) by redline|REDLINE:BEGIN/i;
91
- export function sensitivePathRules(org) {
92
- return SENSITIVE_PATHS.map((pattern) => ({ pattern, owners: [`@${org}/${OWNING_TEAM}`] }));
113
+ /**
114
+ * Who owns the sensitive paths.
115
+ *
116
+ * `owners` are taken as written when given: a team (`@org/team`), a user
117
+ * (`@person`) and an email are all valid CODEOWNERS entries, and Redline is not
118
+ * the right place to decide which an organisation uses. A bare name is qualified
119
+ * with the org, because `@team` alone means a *user* to GitHub and would silently
120
+ * own nothing.
121
+ */
122
+ export function sensitivePathRules(org, owners) {
123
+ const resolved = owners !== undefined && owners.length > 0
124
+ ? owners.map((owner) => qualifyOwner(org, owner))
125
+ : [`@${org}/${OWNING_TEAM}`];
126
+ return SENSITIVE_PATHS.map((pattern) => ({ pattern, owners: resolved }));
127
+ }
128
+ // CODEOWNERS distinguishes the three by shape, so this only ever adds what is
129
+ // missing: `@name` is a USER and is left alone — qualifying it to `@org/name`
130
+ // would turn a person into a team that does not exist — `@org/team` and an email
131
+ // address are already complete, and only a bare word is ambiguous enough to need
132
+ // the organisation putting in front of it.
133
+ function qualifyOwner(org, owner) {
134
+ const trimmed = owner.trim();
135
+ if (trimmed.startsWith('@'))
136
+ return trimmed;
137
+ if (trimmed.includes('@'))
138
+ return trimmed;
139
+ return trimmed.includes('/') ? `@${trimmed}` : `@${org}/${trimmed}`;
93
140
  }
94
141
  export const ONBOARD_BRANCH = 'redline/onboard';
95
142
  // Local and free — both adapters read the checkout and nothing else — but the
@@ -172,7 +219,7 @@ const VENDOR_MARKERS = [
172
219
  // org-enabled vendor) rather than an empty selection — that is the greenfield
173
220
  // case the standard is written for, not a repository that opted out of all of
174
221
  // them.
175
- function detectVendors(cwd, orgDefault) {
222
+ export function detectVendors(cwd, orgDefault) {
176
223
  const found = VENDOR_MARKERS.filter((marker) => marker.paths.some((relPath) => existsSync(join(cwd, relPath)))).map((marker) => marker.vendor);
177
224
  return found.length > 0 ? found : orgDefault;
178
225
  }
@@ -209,6 +256,7 @@ export async function init(platform, opts) {
209
256
  const repair = opts.repair === true;
210
257
  const manifest = loadManifest(root);
211
258
  const now = opts.now ?? (() => new Date());
259
+ const step = opts.onStep ?? (() => { });
212
260
  // Profile resolution happens before any host call or write: a bad --profile
213
261
  // flag must fail clean, with nothing on disk and nothing sent to the host.
214
262
  const detected = opts.profile ?? proposeProfile(scanRepo(cwd)).profile;
@@ -237,6 +285,12 @@ export async function init(platform, opts) {
237
285
  // enforcement keeps what the repository already had: a re-run for an unrelated
238
286
  // reason silently promoting a repository is how a ladder loses the trust it
239
287
  // exists to build.
288
+ // Absent keeps what the repository recorded, for the same reason the rung
289
+ // does: `local` is the weaker control, and a re-run that said nothing about
290
+ // the gate must not be able to move a repository onto it — or, just as bad,
291
+ // silently move a deliberately-local repository back to an organisation gate
292
+ // that does not exist and lose it the gate entirely.
293
+ let gateSource = opts.gateSource ?? existing?.gateSource ?? 'org';
240
294
  const currentRung = existing?.rung ?? 'observe';
241
295
  const rungNotes = [];
242
296
  let rung = currentRung;
@@ -268,7 +322,12 @@ export async function init(platform, opts) {
268
322
  }
269
323
  // A dry run must work offline and with an unscoped token: repoRef() is a
270
324
  // live GET, so the plan is built from what the local clone already knows.
271
- const ref = dryRun ? platform.localRef(cwd) : await platform.repoRef(cwd);
325
+ // `--no-commit` is under the same rule and for the same reason — it is
326
+ // documented as contacting no host, and this read is a host contact whatever
327
+ // else the run goes on to skip.
328
+ const offline = dryRun || opts.noCommit === true;
329
+ step(offline ? 'reading the repository' : `reading ${platform.host}`);
330
+ const ref = offline ? platform.localRef(cwd) : await platform.repoRef(cwd);
272
331
  // detected <- what this repository already recorded <- what the caller
273
332
  // typed, the same precedence the menu resolves under. The org ceiling is
274
333
  // deliberately not applied to the RECORD: render() enforces it on every call
@@ -278,16 +337,55 @@ export async function init(platform, opts) {
278
337
  .filter(([, v]) => v.enabled)
279
338
  .map(([k]) => k);
280
339
  const vendors = opts.vendors ?? existing?.vendors ?? detectVendors(cwd, orgVendors);
281
- const gateOptions = {
340
+ // The same courtesy Spec Kit gets, generalised to the rest of the toolchain.
341
+ // A repository with a mature pipeline already runs a scanner for most of what
342
+ // the gate would add; installing a second one beside it is not defence in
343
+ // depth, it is two sets of findings and two exemption paths for one problem.
344
+ // What Redline still brings such a repository is the part nothing else does —
345
+ // the standards the AI reviews against, and a check that they were applied —
346
+ // so detection narrows the gate rather than cancelling the onboarding.
347
+ const survey = surveyRepo(cwd);
348
+ // Precedence, strongest first: what the caller stated this run, then what the
349
+ // repository recorded, then what detection found. Someone who corrected the
350
+ // survey once must not have to correct it again on every re-run.
351
+ const stated = opts.integrations ?? (existing !== null && existing.integrations.length > 0 ? existing.integrations : null);
352
+ const integrations = stated ?? survey.tools.map((tool) => tool.id);
353
+ const evidenceOf = new Map(survey.tools.map((tool) => [tool.id, tool.evidence]));
354
+ // Which of the gate's own jobs those tools make redundant. It is computed
355
+ // here, above the gate options, because the planning pass and the real
356
+ // install must build the SAME caller workflow: a stand-down list that
357
+ // appeared between them would make the two disagree about whether the file
358
+ // changed, which is the class of bug that plans a path nothing writes.
359
+ //
360
+ // Every tool on the list narrows the gate, whatever put it there. Detection
361
+ // deselects rather than duplicates — that is what cli/detect/existing.ts is
362
+ // for — and the wizard hands its own findings back through --integrations, so
363
+ // treating a detected tool as weaker than a typed one would make the same
364
+ // repository behave differently depending on which entry point ran it.
365
+ //
366
+ // `dependencies` and `secrets` are the two jobs no label can waive, so this
367
+ // is a security decision and never a silent one: every stand-down names the
368
+ // tool, the marker that proved it, and the flag that puts the job back.
369
+ const standDown = [
370
+ ...new Set(integrations.flatMap((id) => {
371
+ const covers = TOOL_PROBES.find((probe) => probe.id === id)?.standsDown;
372
+ return covers === undefined || covers === null ? [] : [covers];
373
+ })),
374
+ ];
375
+ let gateOptions = {
282
376
  ...FLOOR_GATE,
377
+ ...(opts.pipeline ? { pipeline: opts.pipeline } : {}),
283
378
  ...(menu.adrForLargeDiffs ? {} : { adrDiffThreshold: Number.MAX_SAFE_INTEGER }),
284
379
  ...(opts.adoptCaller === true ? { adoptCaller: true } : {}),
285
380
  ...(capabilities.labels ? {} : { manageLabels: false }),
381
+ ...(standDown.length > 0 ? { standDown } : {}),
382
+ ...(gateSource === 'local' ? { gateSource } : {}),
383
+ ...(opts.noCommit === true ? { offline: true } : {}),
286
384
  // Written into the caller workflow, so the gate blocks or reports according
287
385
  // to the rung recorded here rather than needing a second source of truth.
288
386
  rung,
289
387
  };
290
- const ownershipRules = sensitivePathRules(ref.org);
388
+ const ownershipRules = sensitivePathRules(ref.org, opts.reviewOwners);
291
389
  // The gate and ownership file diffs are computed in check mode FIRST, before
292
390
  // a single host setting is touched. Without it there was no way to know a
293
391
  // re-run had nothing to do until four host objects had already been
@@ -299,8 +397,13 @@ export async function init(platform, opts) {
299
397
  // Redline, and its message says nothing was written; planning after the
300
398
  // render made that untrue, leaving every vendor artifact and command file on
301
399
  // disk with no .redline.json, no branch and no pull request to carry them.
302
- const gatePlan = capabilities.gate
303
- ? await platform.installGate(ref, cwd, gateOptions, true)
400
+ // `preflight: !dryRun` is what makes this pass a real plan rather than an
401
+ // optimistic one: a live run may ask the host whether the reusable workflow
402
+ // exists, and must, because the answer decides which paths it will stage. A
403
+ // dry run may not — it contacts no host and needs no credential.
404
+ step('planning the change');
405
+ let gatePlan = capabilities.gate
406
+ ? await platform.installGate(ref, cwd, { ...gateOptions, preflight: !dryRun }, true)
304
407
  : { files: [], outcomes: [] };
305
408
  const ownershipPlan = menu.sensitivePathReviewers
306
409
  ? await platform.ensureReviewOwnership(ref, cwd, ownershipRules, true)
@@ -309,6 +412,39 @@ export async function init(platform, opts) {
309
412
  // acting on any of it. Detection informs; it does not decide.
310
413
  const machinery = observeGateMachinery(platform, cwd);
311
414
  const notes = [...rungNotes];
415
+ // The organisation publishes no gate, and this run has written nothing yet.
416
+ // Offer the repository the one that fits in it, and re-plan so both passes
417
+ // agree on the paths — planning one gate source and installing another is
418
+ // exactly how a run stages a file nothing wrote.
419
+ if (gatePlan.vendorableGate === true && opts.onGateFallback !== undefined) {
420
+ const detail = `${ref.org}/.github publishes no Redline gate`;
421
+ if (await opts.onGateFallback(detail)) {
422
+ gateSource = 'local';
423
+ gateOptions = { ...gateOptions, gateSource };
424
+ gatePlan = await platform.installGate(ref, cwd, { ...gateOptions, preflight: !dryRun }, true);
425
+ notes.push(`${detail}, so the gate is vendored into this repository instead`);
426
+ }
427
+ }
428
+ // Said on every local run, not only the one that chose it here: a repository
429
+ // that recorded `local` three runs ago carries the same property and the same
430
+ // exposure, and a note that appears once at onboarding and never again is a
431
+ // note nobody reads at the moment it matters.
432
+ if (capabilities.gate && gateSource === 'local') {
433
+ notes.push(`the gate is vendored at ${VENDORED_GATE_PATH} rather than referenced from ` +
434
+ `${ref.org}/.github. It runs from the pull request's own head commit, so a pull ` +
435
+ 'request that edits it changes the gate judging it — including standing down the ' +
436
+ 'dependency and secret jobs, which no label can waive. Move to --gate-source org ' +
437
+ 'once the organisation publishes a gate');
438
+ // The protection already exists and is simply off: `/.github/workflows/` is
439
+ // the first entry in SENSITIVE_PATHS. Naming the command rather than
440
+ // turning it on, because the owner it would write is a guess — a CODEOWNERS
441
+ // line naming a team that does not exist blocks every pull request in the
442
+ // repository, which is worse than the exposure it was meant to close.
443
+ if (!menu.sensitivePathReviewers) {
444
+ notes.push('nothing requires review on .github/workflows/ here, so that edit needs no owner\'s ' +
445
+ 'approval. Re-run with --with review-ownership --review-owners <team> to require one');
446
+ }
447
+ }
312
448
  // Detection, not a decision. `readGateMachinery` is local and free, and what
313
449
  // it gives that nothing else here has is the path this host runs its gate
314
450
  // from — so the only claim made is what else is already sitting in that
@@ -351,9 +487,64 @@ export async function init(platform, opts) {
351
487
  'it — the selection recorded in .redline.json is unchanged, and re-selecting the gate ' +
352
488
  'brings them back');
353
489
  }
490
+ // Spec Kit is a separate tool that scaffolds its own files and carries its own
491
+ // account of how the repository works. Where it is already installed, Redline
492
+ // drops its section rather than adding a second one beside it — and says so,
493
+ // because a context silently missing from the artifacts is indistinguishable
494
+ // from one that was never asked for.
495
+ const specKitAt = detectSpecKit(cwd);
496
+ if (specKitAt !== null && menu.speckit) {
497
+ menu.speckit = false;
498
+ notes.push(`this repository already runs Spec Kit (${specKitAt}), so Redline left the spec-driven ` +
499
+ 'development context out rather than writing a second account of it beside the one Spec ' +
500
+ 'Kit maintains — pass --speckit to include it anyway');
501
+ }
502
+ for (const id of integrations) {
503
+ const probe = TOOL_PROBES.find((p) => p.id === id);
504
+ if (probe === undefined)
505
+ continue;
506
+ const how = evidenceOf.get(id) ?? 'you said so — detection could not see it from the checkout';
507
+ const covers = probe.standsDown;
508
+ notes.push(covers === null
509
+ ? `this repository already runs ${probe.label} (${how}) — noted; Redline installs nothing that overlaps it`
510
+ : `this repository already runs ${probe.label} (${how}), which covers what Redline's own ` +
511
+ `${covers} check would report, so the gate's ${covers} job is stood down here rather ` +
512
+ `than run a second time. To run it anyway, re-run with an --integrations list that ` +
513
+ `leaves ${id} out`);
514
+ }
515
+ // Detected, and then unticked. Worth saying: the next run will not re-detect
516
+ // it into the plan, and a reader of the report should know why it is absent.
517
+ for (const tool of survey.tools) {
518
+ if (!integrations.includes(tool.id)) {
519
+ notes.push(`${tool.label} was detected (${tool.evidence}) but recorded as not in use here`);
520
+ }
521
+ }
522
+ // Controls the operator asked for. Written here rather than by an adapter
523
+ // because they are plain repository files on any host that supports them, and
524
+ // they ride in the same pull request as everything else this run writes.
525
+ const wanted = new Set(opts.setup ?? []);
526
+ const chosen = offerable(cwd, platform.host, stacks).filter(({ integration }) => wanted.has(integration.id));
527
+ const setupResult = applySetup(cwd, chosen, dryRun);
528
+ for (const path of setupResult.skipped) {
529
+ // Never overwritten: a repository with its own renovate.json has a
530
+ // considered one, and replacing it with a generated default is the kind of
531
+ // help that costs a team a week of tuning.
532
+ notes.push(`${path} already exists and was left exactly as it is`);
533
+ }
534
+ for (const { integration } of chosen) {
535
+ if (integration.id === 'renovate' && setupResult.written.includes('renovate.json')) {
536
+ notes.push('renovate.json is written, but Renovate only runs once the Renovate app is installed on ' +
537
+ 'the organisation — the file alone does nothing');
538
+ }
539
+ if (integration.id === 'codeql' && setupResult.written.includes('.github/workflows/codeql.yml')) {
540
+ notes.push('CodeQL is written; on a private repository it needs GitHub Advanced Security to run');
541
+ }
542
+ }
543
+ const contexts = CONTEXTS.filter((context) => menu[context.key]).map((context) => context.key);
354
544
  // Files next, host settings after: a denied host call must never cost the
355
545
  // file-level work that already succeeded.
356
- const rendered = render({ root, profile, out: cwd, vendors, check: dryRun });
546
+ step(dryRun ? 'rendering the standards (plan only)' : 'rendering the standards');
547
+ const rendered = render({ root, profile, out: cwd, vendors, contexts, check: dryRun });
357
548
  // The ceiling render() applies internally, applied here too. renderCommands
358
549
  // cannot enforce it for itself: COMMAND_HOSTS carries hosts the vendor
359
550
  // manifest has no entry for at all (opencode), which is not the same thing
@@ -381,6 +572,7 @@ export async function init(platform, opts) {
381
572
  ...commands.written,
382
573
  ...gatePlan.files,
383
574
  ...ownershipPlan.files,
575
+ ...setupResult.written,
384
576
  ];
385
577
  // A menu change moves no file of its own (the gate template already diffs
386
578
  // adrForLargeDiffs), but it is exactly what `redline init --blocking` on a
@@ -393,7 +585,11 @@ export async function init(platform, opts) {
393
585
  // a selection this run was explicitly told to drop.
394
586
  const vendorsChanged = existing !== null &&
395
587
  (existing.vendors.length !== vendors.length ||
396
- [...existing.vendors].sort().join('') !== [...vendors].sort().join(''));
588
+ // `\0` as the escape, never a literal NUL byte. The byte itself made this
589
+ // file read as binary to grep and ripgrep, which then skipped it silently:
590
+ // a repository-wide search for any symbol in the largest command module
591
+ // returned nothing and reported no error.
592
+ [...existing.vendors].sort().join('\0') !== [...vendors].sort().join('\0'));
397
593
  // Same shape again: deselecting a capability moves no file of its own, and
398
594
  // swallowing it as "nothing to change" would leave .redline.json recording a
399
595
  // capability the operator just switched off — with Redline still maintaining it.
@@ -481,11 +677,11 @@ export async function init(platform, opts) {
481
677
  hostPlan,
482
678
  };
483
679
  }
484
- const files = [...changedFiles, CONFIG_FILE];
680
+ const plannedFiles = [...changedFiles, CONFIG_FILE];
485
681
  if (dryRun) {
486
682
  return {
487
683
  profile,
488
- files,
684
+ files: plannedFiles,
489
685
  removals,
490
686
  // installGate's plan reports no outcome (it made no host call);
491
687
  // ensureReviewOwnership's are decided locally and worth printing.
@@ -503,13 +699,78 @@ export async function init(platform, opts) {
503
699
  hostPlan,
504
700
  };
505
701
  }
702
+ step('installing the merge gate');
506
703
  const gate = capabilities.gate
507
704
  ? await platform.installGate(ref, cwd, gateOptions)
508
705
  : { files: [], outcomes: [] };
706
+ // Files written, nothing else attempted. Everything below this point either
707
+ // changes a setting on the host or puts a commit in the repository's history,
708
+ // and `--no-commit` exists precisely to reach neither.
709
+ //
710
+ // The config is written here rather than at the shared site below so this
711
+ // path records what it actually did: a repository whose settings were never
712
+ // applied must not carry a pendingAdmin list computed from outcomes that
713
+ // never happened, and must not read back as fully onboarded on the next run.
714
+ if (opts.noCommit === true) {
715
+ if (capabilities.gate && gateSource === 'org') {
716
+ notes.push(`the caller workflow references ${ref.org}/.github and nothing checked that it publishes ` +
717
+ 'the gate, because --no-commit contacts no host. Run redline verify after you commit, ' +
718
+ 'or re-run without --no-commit, before relying on the check');
719
+ }
720
+ const written = [
721
+ ...rendered.written,
722
+ ...removals,
723
+ ...commands.written,
724
+ ...gate.files,
725
+ ...setupResult.written,
726
+ CONFIG_FILE,
727
+ ];
728
+ step('recording .redline.json');
729
+ writeConfig(cwd, {
730
+ standardsVersion: manifest.version,
731
+ cliVersion: CLI_VERSION,
732
+ host: platform.host,
733
+ profile,
734
+ vendors,
735
+ menu,
736
+ capabilities,
737
+ integrations,
738
+ // Nothing was applied, so nothing is owed to an administrator yet. The
739
+ // run that does apply them computes this from real outcomes.
740
+ pendingAdmin: existing?.pendingAdmin ?? [],
741
+ onboardedAt: existing?.onboardedAt ?? now().toISOString(),
742
+ lastRunAt: now().toISOString(),
743
+ localRules: existsSync(join(cwd, LOCAL_RULES_FILE)),
744
+ commandFiles: commands.contentIds,
745
+ rung,
746
+ gateSource,
747
+ gateVersion: gateSource === 'local' && CLI_VERSION !== UNPUBLISHED_VERSION ? CLI_VERSION : '',
748
+ });
749
+ return {
750
+ profile,
751
+ files: written,
752
+ removals,
753
+ outcomes: [],
754
+ pendingAdmin: existing?.pendingAdmin ?? [],
755
+ pullRequest: null,
756
+ pullRequestError: null,
757
+ migratedFrom,
758
+ alreadyOnboarded: false,
759
+ dryRun: false,
760
+ noCommit: true,
761
+ menu,
762
+ capabilities,
763
+ optedOut,
764
+ notes,
765
+ hostPlan,
766
+ };
767
+ }
509
768
  const ownership = menu.sensitivePathReviewers
510
769
  ? await platform.ensureReviewOwnership(ref, cwd, ownershipRules)
511
770
  : { files: [], outcomes: [] };
771
+ step('applying the security floor');
512
772
  const security = await platform.enableSecurityFloor(ref);
773
+ step('applying the branch policy');
513
774
  const policy = capabilities.mergePolicy
514
775
  ? await platform.applyPolicy(ref, {
515
776
  requiredApprovals: 1,
@@ -522,13 +783,28 @@ export async function init(platform, opts) {
522
783
  // MergePolicy — what verify reports back off the host.
523
784
  requiredChecks: [],
524
785
  blocking: menu.blockingGate,
786
+ ...(opts.branches && opts.branches.length > 0 ? { branches: opts.branches } : {}),
525
787
  })
526
788
  : { outcomes: [], policy: null };
789
+ // The check pass cannot perform host preflight reads, so an adapter may
790
+ // suppress a planned file during the real install. Stage only what the
791
+ // installers actually wrote; otherwise git add receives a path that does
792
+ // not exist.
793
+ const files = [
794
+ ...rendered.written,
795
+ ...removals,
796
+ ...commands.written,
797
+ ...gate.files,
798
+ ...ownership.files,
799
+ ...setupResult.written,
800
+ CONFIG_FILE,
801
+ ];
527
802
  // denied -> pendingAdmin work for an administrator; unsupported -> the
528
803
  // capability doesn't exist on this repository (e.g. Advanced Security is
529
804
  // unlicensed) and must never be reported as permanently half-onboarded.
530
805
  const outcomes = [...gate.outcomes, ...ownership.outcomes, ...security.outcomes, ...policy.outcomes];
531
806
  const pendingAdmin = outcomes.filter(isPending).map((o) => o.capability);
807
+ step('recording .redline.json');
532
808
  writeConfig(cwd, {
533
809
  standardsVersion: manifest.version,
534
810
  cliVersion: CLI_VERSION,
@@ -537,6 +813,7 @@ export async function init(platform, opts) {
537
813
  vendors,
538
814
  menu,
539
815
  capabilities,
816
+ integrations,
540
817
  pendingAdmin,
541
818
  // When the repository joined, not when it was last touched: overwriting
542
819
  // this on every run erased the only record of when the standard landed.
@@ -549,14 +826,21 @@ export async function init(platform, opts) {
549
826
  // a re-run for an unrelated reason silently promoting a repository is how a
550
827
  // ladder loses the trust it exists to build.
551
828
  rung,
829
+ gateSource,
830
+ // Only a vendored gate has a version to record, and only a published CLI
831
+ // stamps one: a development build leaves the reusable copy's own pin alone
832
+ // rather than writing a version npm has never heard of, so there is nothing
833
+ // for verify to compare and the field stays empty.
834
+ gateVersion: gateSource === 'local' && CLI_VERSION !== UNPUBLISHED_VERSION ? CLI_VERSION : '',
552
835
  });
836
+ step('committing and opening the pull request');
553
837
  let pullRequest = null;
554
838
  let pullRequestError = null;
555
839
  try {
556
840
  pullRequest = await platform.openPullRequest(ref, cwd, {
557
841
  branch: ONBOARD_BRANCH,
558
842
  title: `chore(redline): onboard to standards v${manifest.version}`,
559
- body: onboardBody(profile, manifest.version, pendingAdmin),
843
+ body: onboardBody(profile, manifest.version, pendingAdmin, files, hostPlan, menu, notes),
560
844
  labels: capabilities.labels ? [SYNC_LABEL] : [],
561
845
  files,
562
846
  });
@@ -594,22 +878,57 @@ export async function init(platform, opts) {
594
878
  hostPlan,
595
879
  };
596
880
  }
597
- function onboardBody(profile, version, pendingAdmin) {
881
+ // The pull request a team sees first, and usually the only thing they read
882
+ // before deciding whether this tool is worth having. It used to open with
883
+ // "Onboards this repository to Redline standards v0.0.3" — a sentence that
884
+ // means nothing to a reviewer who has not heard of Redline, followed by three
885
+ // paragraphs of Redline's own vocabulary and no statement of what changed in
886
+ // THEIR repository or what happens next. So: what it does, what it changed
887
+ // here, what the reviewer will notice, and how to switch any of it off.
888
+ function onboardBody(profile, version, pendingAdmin, files, hostPlan, menu, notes) {
889
+ const bullets = (items) => items.map((item) => `- \`${item}\``);
890
+ const gate = menu.blockingGate
891
+ ? 'The gate **blocks** a merge that fails it.'
892
+ : 'The gate is **advisory**: it reports and does not block. Making it blocking is a separate, ' +
893
+ 'deliberate step once you have watched it for a while.';
598
894
  const pending = pendingAdmin.length === 0
599
- ? 'Everything that needed repository settings was applied.'
600
- : `A repository administrator still needs to enable: ${pendingAdmin.join(', ')}. ` +
601
- `Until then this repository shows as partially onboarded.`;
895
+ ? []
896
+ : [
897
+ '',
898
+ '## Needs an administrator',
899
+ '',
900
+ `These could not be applied with the permissions this run had: ${pendingAdmin.join(', ')}.`,
901
+ 'Everything else is in place; re-run `redline init --repair` once they are granted.',
902
+ ];
602
903
  return [
603
- `Onboards this repository to Redline standards \`v${version}\` (profile: \`${profile}\`).`,
904
+ 'This adds an automated review standard to the repository: one versioned set of rules, rendered',
905
+ 'into the files your coding assistants already read, plus a pull request check that applies them',
906
+ 'to the diff.',
907
+ '',
908
+ `Detected stack: \`${profile}\`. Rules version: \`v${version}\`.`,
909
+ '',
910
+ '## What changed here',
911
+ '',
912
+ ...(files.length === 0 ? ['No files changed.'] : bullets(files)),
913
+ ...(hostPlan.length === 0 ? [] : ['', 'Repository settings:', '', ...hostPlan.map((h) => `- ${h}`)]),
914
+ '',
915
+ '## What you will notice',
916
+ '',
917
+ `- ${gate}`,
918
+ '- Your next pull request runs the Redline check and comments findings on the diff.',
919
+ '- Nothing outside the `<!-- REDLINE:BEGIN -->` markers was touched. Files you already had —',
920
+ ' a pull request template, a CODEOWNERS — were left exactly as they are.',
604
921
  '',
605
- 'The merge gate runs **advisory** — it reports, it does not block. Promotion to blocking is a',
606
- 'deliberate second step after a soak period.',
922
+ '## Turning it down',
607
923
  '',
608
- 'Generated content sits inside `<!-- REDLINE:BEGIN -->` markers; anything outside them is yours',
609
- 'and was preserved. If a rule is wrong for this repository, raise it in the Redline source repo',
610
- 'rather than editing it here, so every repository benefits.',
924
+ '- A capability you already have your own version of: `redline init --skip <name>`.',
925
+ '- A rule that is wrong for this repository: raise it in the Redline repository rather than',
926
+ ' editing the generated block here, so every repository gets the fix.',
927
+ '- All of it: `redline remove` takes back only what Redline can prove it wrote, as a pull request.',
611
928
  '',
612
- pending,
929
+ '`.redline.json` records every choice above and explains each one in its own `//` key.',
930
+ ...(notes.length === 0 ? [] : ['', '## Worth knowing', '', ...notes.map((n) => `- ${n}`)]),
931
+ ...pending,
613
932
  ].join('\n');
614
933
  }
615
934
  //# sourceMappingURL=init.js.map