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.
- package/README.md +23 -7
- package/dist/bin/redline.js +338 -43
- package/dist/bin/redline.js.map +1 -1
- package/dist/commands/init.js +353 -34
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/remove.js +43 -1
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/status.js +94 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/verify.js +82 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/config/redline-json.js +63 -2
- package/dist/config/redline-json.js.map +1 -1
- package/dist/core/git.js +50 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/version.js +6 -0
- package/dist/core/version.js.map +1 -1
- package/dist/detect/existing.js +151 -0
- package/dist/detect/existing.js.map +1 -0
- package/dist/detect/setup.js +200 -0
- package/dist/detect/setup.js.map +1 -0
- package/dist/detect/stack.js +56 -17
- package/dist/detect/stack.js.map +1 -1
- package/dist/exempt/parse.js +8 -1
- package/dist/exempt/parse.js.map +1 -1
- package/dist/metrics/options.js +0 -9
- package/dist/metrics/options.js.map +1 -1
- package/dist/platforms/azure/install.js +9 -9
- package/dist/platforms/azure/install.js.map +1 -1
- package/dist/platforms/azure/verify.js +10 -2
- package/dist/platforms/azure/verify.js.map +1 -1
- package/dist/platforms/github/install.js +192 -26
- package/dist/platforms/github/install.js.map +1 -1
- package/dist/platforms/github/preflight.js +63 -0
- package/dist/platforms/github/preflight.js.map +1 -0
- package/dist/platforms/github/vendor.js +81 -0
- package/dist/platforms/github/vendor.js.map +1 -0
- package/dist/platforms/github/verify.js +79 -7
- package/dist/platforms/github/verify.js.map +1 -1
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/types.js.map +1 -1
- package/dist/policy/diff.js +26 -9
- package/dist/policy/diff.js.map +1 -1
- package/dist/render/contexts.js +39 -0
- package/dist/render/contexts.js.map +1 -0
- package/dist/render/profile.js +44 -10
- package/dist/render/profile.js.map +1 -1
- package/dist/render/standards.js +10 -1
- package/dist/render/standards.js.map +1 -1
- package/dist/render/vendors.js +14 -53
- package/dist/render/vendors.js.map +1 -1
- package/dist/rules/catalogue.js +127 -0
- package/dist/rules/catalogue.js.map +1 -0
- package/dist/ui/facts.js +111 -0
- package/dist/ui/facts.js.map +1 -0
- package/dist/ui/prompt.js +330 -0
- package/dist/ui/prompt.js.map +1 -0
- package/dist/ui/report.js +214 -0
- package/dist/ui/report.js.map +1 -0
- package/dist/ui/tty.js +576 -0
- package/dist/ui/tty.js.map +1 -0
- package/dist/ui/wizard.js +293 -0
- package/dist/ui/wizard.js.map +1 -0
- package/dist/verify/remote.js +33 -0
- package/dist/verify/remote.js.map +1 -1
- package/package.json +13 -1
- package/platforms/azure/gate-template-github.yml +152 -0
- package/platforms/azure/gate-template.yml +59 -6
- package/scripts/check-pins.mjs +84 -1
- package/scripts/fetch-stars.mjs +92 -0
- package/scripts/lib/rules.d.mts +18 -0
- package/scripts/publish-local.mjs +183 -0
- package/standards/contexts/speckit.md +22 -0
- package/standards/contexts/tmf.md +25 -0
- package/standards/manifest.json +78 -7
- package/standards/stacks/angular.md +57 -0
- package/standards/stacks/dom.md +61 -0
- package/standards/stacks/svelte.md +45 -0
- package/standards/stacks/vue.md +54 -0
- package/templates/redline.yml +9 -9
- package/workflows/dashboard.yml +1 -1
- package/workflows/redline-collect.yml +1 -1
- package/workflows/redline-gate.yml +38 -7
- package/workflows/seed-canary.yml +2 -2
- package/workflows/weekly-digest.yml +1 -1
- package/scripts/measure-context.mjs +0 -101
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 = [
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
?
|
|
600
|
-
:
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
606
|
-
'deliberate second step after a soak period.',
|
|
922
|
+
'## Turning it down',
|
|
607
923
|
'',
|
|
608
|
-
'
|
|
609
|
-
'
|
|
610
|
-
'
|
|
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
|
-
|
|
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
|