redlinegate 0.0.1

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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/commands/redline-init.md +54 -0
  4. package/commands/redline-verify.md +69 -0
  5. package/dist/bin/redline.js +605 -0
  6. package/dist/bin/redline.js.map +1 -0
  7. package/dist/commands/exempt.js +45 -0
  8. package/dist/commands/exempt.js.map +1 -0
  9. package/dist/commands/init.js +615 -0
  10. package/dist/commands/init.js.map +1 -0
  11. package/dist/commands/policy.js +25 -0
  12. package/dist/commands/policy.js.map +1 -0
  13. package/dist/commands/remove.js +427 -0
  14. package/dist/commands/remove.js.map +1 -0
  15. package/dist/commands/review.js +72 -0
  16. package/dist/commands/review.js.map +1 -0
  17. package/dist/commands/sync.js +42 -0
  18. package/dist/commands/sync.js.map +1 -0
  19. package/dist/commands/verify.js +478 -0
  20. package/dist/commands/verify.js.map +1 -0
  21. package/dist/config/redline-json.js +187 -0
  22. package/dist/config/redline-json.js.map +1 -0
  23. package/dist/core/errors.js +25 -0
  24. package/dist/core/errors.js.map +1 -0
  25. package/dist/core/git.js +152 -0
  26. package/dist/core/git.js.map +1 -0
  27. package/dist/core/log.js +22 -0
  28. package/dist/core/log.js.map +1 -0
  29. package/dist/core/severity.js +16 -0
  30. package/dist/core/severity.js.map +1 -0
  31. package/dist/core/version.js +15 -0
  32. package/dist/core/version.js.map +1 -0
  33. package/dist/detect/scan.js +79 -0
  34. package/dist/detect/scan.js.map +1 -0
  35. package/dist/detect/stack.js +71 -0
  36. package/dist/detect/stack.js.map +1 -0
  37. package/dist/enforce/ladder.js +148 -0
  38. package/dist/enforce/ladder.js.map +1 -0
  39. package/dist/exempt/parse.js +86 -0
  40. package/dist/exempt/parse.js.map +1 -0
  41. package/dist/metrics/options.js +257 -0
  42. package/dist/metrics/options.js.map +1 -0
  43. package/dist/metrics/run.js +72 -0
  44. package/dist/metrics/run.js.map +1 -0
  45. package/dist/platforms/azure/client.js +53 -0
  46. package/dist/platforms/azure/client.js.map +1 -0
  47. package/dist/platforms/azure/index.js +69 -0
  48. package/dist/platforms/azure/index.js.map +1 -0
  49. package/dist/platforms/azure/install.js +917 -0
  50. package/dist/platforms/azure/install.js.map +1 -0
  51. package/dist/platforms/azure/policy-types.js +70 -0
  52. package/dist/platforms/azure/policy-types.js.map +1 -0
  53. package/dist/platforms/azure/verify.js +309 -0
  54. package/dist/platforms/azure/verify.js.map +1 -0
  55. package/dist/platforms/detect.js +40 -0
  56. package/dist/platforms/detect.js.map +1 -0
  57. package/dist/platforms/github/client.js +49 -0
  58. package/dist/platforms/github/client.js.map +1 -0
  59. package/dist/platforms/github/index.js +54 -0
  60. package/dist/platforms/github/index.js.map +1 -0
  61. package/dist/platforms/github/install.js +558 -0
  62. package/dist/platforms/github/install.js.map +1 -0
  63. package/dist/platforms/github/push.js +102 -0
  64. package/dist/platforms/github/push.js.map +1 -0
  65. package/dist/platforms/github/remote.js +36 -0
  66. package/dist/platforms/github/remote.js.map +1 -0
  67. package/dist/platforms/github/verify.js +366 -0
  68. package/dist/platforms/github/verify.js.map +1 -0
  69. package/dist/platforms/http.js +58 -0
  70. package/dist/platforms/http.js.map +1 -0
  71. package/dist/platforms/pull-request-templates.js +157 -0
  72. package/dist/platforms/pull-request-templates.js.map +1 -0
  73. package/dist/platforms/remote.js +21 -0
  74. package/dist/platforms/remote.js.map +1 -0
  75. package/dist/platforms/resolve.js +69 -0
  76. package/dist/platforms/resolve.js.map +1 -0
  77. package/dist/platforms/shape.js +19 -0
  78. package/dist/platforms/shape.js.map +1 -0
  79. package/dist/platforms/types.js +24 -0
  80. package/dist/platforms/types.js.map +1 -0
  81. package/dist/policy/checks.js +91 -0
  82. package/dist/policy/checks.js.map +1 -0
  83. package/dist/policy/diff.js +50 -0
  84. package/dist/policy/diff.js.map +1 -0
  85. package/dist/registry/discover.js +74 -0
  86. package/dist/registry/discover.js.map +1 -0
  87. package/dist/registry/serialize.js +41 -0
  88. package/dist/registry/serialize.js.map +1 -0
  89. package/dist/registry/types.js +2 -0
  90. package/dist/registry/types.js.map +1 -0
  91. package/dist/remove/host.js +294 -0
  92. package/dist/remove/host.js.map +1 -0
  93. package/dist/render/commands.js +139 -0
  94. package/dist/render/commands.js.map +1 -0
  95. package/dist/render/manifest.js +82 -0
  96. package/dist/render/manifest.js.map +1 -0
  97. package/dist/render/markers.js +166 -0
  98. package/dist/render/markers.js.map +1 -0
  99. package/dist/render/profile.js +23 -0
  100. package/dist/render/profile.js.map +1 -0
  101. package/dist/render/standards.js +169 -0
  102. package/dist/render/standards.js.map +1 -0
  103. package/dist/render/vendors.js +199 -0
  104. package/dist/render/vendors.js.map +1 -0
  105. package/dist/review/engines/api.js +95 -0
  106. package/dist/review/engines/api.js.map +1 -0
  107. package/dist/review/engines/embedded.js +20 -0
  108. package/dist/review/engines/embedded.js.map +1 -0
  109. package/dist/review/engines/types.js +2 -0
  110. package/dist/review/engines/types.js.map +1 -0
  111. package/dist/review/glob.js +29 -0
  112. package/dist/review/glob.js.map +1 -0
  113. package/dist/review/prompt.js +45 -0
  114. package/dist/review/prompt.js.map +1 -0
  115. package/dist/review/rules.js +25 -0
  116. package/dist/review/rules.js.map +1 -0
  117. package/dist/review/schema.js +84 -0
  118. package/dist/review/schema.js.map +1 -0
  119. package/dist/review/scope.js +27 -0
  120. package/dist/review/scope.js.map +1 -0
  121. package/dist/sarif/map.js +58 -0
  122. package/dist/sarif/map.js.map +1 -0
  123. package/dist/sarif/parse.js +88 -0
  124. package/dist/sarif/parse.js.map +1 -0
  125. package/dist/sarif/types.js +2 -0
  126. package/dist/sarif/types.js.map +1 -0
  127. package/dist/sync/host.js +21 -0
  128. package/dist/sync/host.js.map +1 -0
  129. package/dist/sync/plan.js +47 -0
  130. package/dist/sync/plan.js.map +1 -0
  131. package/dist/sync/render.js +57 -0
  132. package/dist/sync/render.js.map +1 -0
  133. package/dist/sync/run.js +118 -0
  134. package/dist/sync/run.js.map +1 -0
  135. package/dist/verify/host.js +52 -0
  136. package/dist/verify/host.js.map +1 -0
  137. package/dist/verify/remote.js +146 -0
  138. package/dist/verify/remote.js.map +1 -0
  139. package/package.json +52 -0
  140. package/platforms/azure/gate-template.yml +105 -0
  141. package/rulesets/redline-org-ruleset.json +46 -0
  142. package/rulesets/redline-ruleset.json +49 -0
  143. package/scripts/assign-rule-ids.mjs +129 -0
  144. package/scripts/build-baseline.mjs +124 -0
  145. package/scripts/build-correlation.mjs +74 -0
  146. package/scripts/build-dashboard.mjs +584 -0
  147. package/scripts/build-digest.mjs +164 -0
  148. package/scripts/build-inbox.mjs +133 -0
  149. package/scripts/build-registry.mjs +42 -0
  150. package/scripts/build-roi.mjs +181 -0
  151. package/scripts/check-pins.mjs +88 -0
  152. package/scripts/collect-telemetry.mjs +300 -0
  153. package/scripts/lib/__tests__/baseline.test.mjs +132 -0
  154. package/scripts/lib/__tests__/correlate.test.mjs +131 -0
  155. package/scripts/lib/__tests__/dora.test.mjs +110 -0
  156. package/scripts/lib/__tests__/exemptions.test.mjs +85 -0
  157. package/scripts/lib/__tests__/metrics-scanner.test.mjs +65 -0
  158. package/scripts/lib/__tests__/sarif.test.mjs +125 -0
  159. package/scripts/lib/__tests__/spend.test.mjs +64 -0
  160. package/scripts/lib/baseline.mjs +156 -0
  161. package/scripts/lib/correlate.mjs +125 -0
  162. package/scripts/lib/dora.mjs +133 -0
  163. package/scripts/lib/exemptions.mjs +79 -0
  164. package/scripts/lib/metrics.mjs +172 -0
  165. package/scripts/lib/rules.mjs +107 -0
  166. package/scripts/lib/sarif.mjs +108 -0
  167. package/scripts/lib/spend.mjs +69 -0
  168. package/scripts/measure-context.mjs +101 -0
  169. package/scripts/render-self.mjs +18 -0
  170. package/scripts/score-seeds.mjs +294 -0
  171. package/scripts/validate.mjs +394 -0
  172. package/standards/core.md +124 -0
  173. package/standards/manifest.json +214 -0
  174. package/standards/stacks/csharp.md +29 -0
  175. package/standards/stacks/go.md +30 -0
  176. package/standards/stacks/java.md +30 -0
  177. package/standards/stacks/javascript.md +42 -0
  178. package/standards/stacks/kotlin.md +27 -0
  179. package/standards/stacks/microservices.md +37 -0
  180. package/standards/stacks/nodejs.md +34 -0
  181. package/standards/stacks/python.md +29 -0
  182. package/standards/stacks/react-native.md +29 -0
  183. package/standards/stacks/react.md +46 -0
  184. package/standards/stacks/swift.md +26 -0
  185. package/standards/stacks/terraform.md +27 -0
  186. package/templates/CODEOWNERS +29 -0
  187. package/templates/azure/pull_request_template.md +72 -0
  188. package/templates/github/pull_request_template.md +72 -0
  189. package/templates/redline.yml +35 -0
  190. package/templates/repo-context.md +64 -0
  191. package/workflows/dashboard.yml +119 -0
  192. package/workflows/inbox.yml +97 -0
  193. package/workflows/redline-collect.yml +61 -0
  194. package/workflows/redline-gate.yml +301 -0
  195. package/workflows/redline-sync.yml +79 -0
  196. package/workflows/seed-canary.yml +231 -0
  197. package/workflows/verify-onboarding.yml +115 -0
  198. package/workflows/weekly-digest.yml +78 -0
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CLI_VERSION } from "../core/version.js";
6
+ import { createLog } from "../core/log.js";
7
+ import { exitCodeFor, isRedlineError, RedlineError } from "../core/errors.js";
8
+ import { isSeverity } from "../core/severity.js";
9
+ import { isRung, RUNGS } from "../enforce/ladder.js";
10
+ import { resolvePlatform as defaultResolvePlatform, } from "../platforms/resolve.js";
11
+ import { init, ONBOARD_BRANCH } from "../commands/init.js";
12
+ import { capabilitySelection, OPTIONAL_CAPABILITIES, } from "../config/redline-json.js";
13
+ import { remove, REMOVE_BRANCH } from "../commands/remove.js";
14
+ import { createHostWithdrawal } from "../remove/host.js";
15
+ import { verify } from "../commands/verify.js";
16
+ import { sync } from "../commands/sync.js";
17
+ import { exempt } from "../commands/exempt.js";
18
+ import { formatFinding, policy } from "../commands/policy.js";
19
+ import { review } from "../commands/review.js";
20
+ import { renderFinding } from "../review/schema.js";
21
+ import { embedded } from "../review/engines/embedded.js";
22
+ import { createApiEngine } from "../review/engines/api.js";
23
+ import { METRICS_COMMANDS, helpFor } from "../metrics/options.js";
24
+ import { runMetrics, specFor } from "../metrics/run.js";
25
+ import { createSyncHost } from "../sync/host.js";
26
+ import { verifyRemote } from "../verify/remote.js";
27
+ import { createRemoteVerifyHost } from "../verify/host.js";
28
+ import { standardsVersion } from "../commands/sync.js";
29
+ const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
30
+ const USAGE = [
31
+ 'redline — engineering control plane',
32
+ '',
33
+ ' redline init [--profile <name>] [--vendors <list>] [--blocking] [--no-a11y] [--speckit] [--dry-run] [--repair]',
34
+ ' [--adopt-caller] [--skip <list>] [--with <list>]',
35
+ ' onboard this repository: standards, security floor, merge gate (advisory), registration',
36
+ ' --dry-run print the plan; writes nothing, needs no credential, contacts no host',
37
+ ' --vendors <list> comma-separated vendor ids (copilot,agents,claude,cursor) to render for —',
38
+ ' overrides both detection and whatever .redline.json already recorded; a vendor',
39
+ ' the org has not enabled never renders no matter what this list names',
40
+ ' --blocking promote the merge gate from advisory to blocking',
41
+ ' --no-a11y, --speckit recorded in .redline.json for later phases; changes nothing in Phase 1',
42
+ ' --repair re-apply every capability even if this repository looks already onboarded — for',
43
+ ' labels, review-ownership, repo-property, gate and merge-policy, whose recorded',
44
+ ' pendingAdmin entry a plain re-run can never clear on its own; composes with --dry-run',
45
+ ` --skip <list> comma-separated capabilities this repository does not want Redline to install:`,
46
+ ` ${OPTIONAL_CAPABILITIES.join(', ')}. Use it when the repository already has its own —`,
47
+ ' a deselected capability is not attempted, not written, and not reported as missing.',
48
+ ' The security floor (secret scanning, push protection, dependency alerts) is the',
49
+ ' organisation-wide minimum and is refused by name rather than deselected',
50
+ ' --with <list> the same names, selected again — how a deselection recorded in .redline.json is',
51
+ ' reversed',
52
+ ` --rung <name> the enforcement rung: ${RUNGS.join(', ')}. A promotion needs recorded`,
53
+ ' evidence and is refused without it; a demotion is always allowed. Omitting',
54
+ ' the flag keeps whatever the repository already recorded',
55
+ ' --adopt-caller let Redline take over the gate machinery file (.github/workflows/redline.yml,',
56
+ ' .azuredevops/redline-gate.yml) when what is already there carries nothing that',
57
+ ' attributes it to Redline — a 2.1 caller, in practice. Without it the run refuses',
58
+ ' rather than overwrite a file that may be the repository\'s own',
59
+ ' omitted flags keep whatever .redline.json already recorded',
60
+ '',
61
+ ' redline remove [--dry-run]',
62
+ ' take Redline back out of this repository: rendered standards, gate machinery, slash',
63
+ ' commands, the host state it applied, and .redline.json last of all — as a pull request',
64
+ ' --dry-run print the plan; writes nothing, needs no credential, contacts no host',
65
+ ' only content Redline can prove it wrote is removed. A merged file keeps every byte',
66
+ ' outside its REDLINE block; anything unattributable is left in place and named',
67
+ ' the security floor (secret scanning, push protection, dependency alerts) is the',
68
+ ' organisation\'s minimum, not Redline\'s state — no flag here turns it off',
69
+ '',
70
+ ' redline verify [--gate] [--repo <owner/name>]',
71
+ ' check this repository still matches what .redline.json claims',
72
+ ' --repo <owner/name> check a repository over the API, with no checkout — a check',
73
+ ' that genuinely needs a working tree reports ?? rather than passing',
74
+ '',
75
+ ' redline review [--staged] [--diff-file <path>] [--base <ref>] [--engine <name>]',
76
+ ' review this change against ONLY the rules that apply to the files it touches',
77
+ ' --engine embedded emit the bounded prompt for the assistant running this (default)',
78
+ ' --engine api call a configured endpoint — local or hosted — and parse the result',
79
+ ' local findings are never sent to the telemetry that tunes rules',
80
+ '',
81
+ ' redline policy --diff-file <path>',
82
+ ' evaluate the rules a checker can decide, with no model call. Exit 1 on a BLOCKER',
83
+ '',
84
+ ' redline exempt --body-file <path> [--scope <check>]',
85
+ ' decide whether a pull request carries a valid exemption for a failing process',
86
+ ' check — a reason, an actor and an expiry, not a bare label. Exit 0 if it applies',
87
+ '',
88
+ ' redline sync [--dry-run] [--repo <owner/name>] [--force]',
89
+ ' open a pull request on every registered repository whose standards are behind',
90
+ ' --dry-run print the plan; opens nothing, pushes nothing',
91
+ ' --repo <owner/name> one repository instead of the estate',
92
+ ' --force re-render a repository already at the current standards version',
93
+ ' run from a checkout of the Redline source repository, not a product repo',
94
+ '',
95
+ ' redline metrics <command> [--flags]',
96
+ ` the estate's measurement plane: ${Object.keys(METRICS_COMMANDS).join(', ')}`,
97
+ ' run `redline metrics <command> --help` for its flags. These act on an organisation',
98
+ ' or its collected telemetry, not on the repository you are standing in',
99
+ '',
100
+ ' redline registry [--flags]',
101
+ ' derive the register of onboarded repositories by walking the org',
102
+ '',
103
+ ' redline --version',
104
+ ].join('\n');
105
+ // node:util's parseArgs throws a plain Error on an unrecognised flag — that
106
+ // is bad input, not an internal defect, so it is converted to a usage
107
+ // RedlineError right at its own call site rather than left for the run()
108
+ // catch-all below to misclassify.
109
+ function parseCliArgs(fn) {
110
+ try {
111
+ return fn();
112
+ }
113
+ catch (error) {
114
+ throw new RedlineError('usage', error instanceof Error ? error.message : String(error));
115
+ }
116
+ }
117
+ export async function run(argv, deps = {}) {
118
+ const cwd = deps.cwd ?? process.cwd();
119
+ const root = deps.root ?? PACKAGE_ROOT;
120
+ const log = createLog(deps.sink);
121
+ const resolve = deps.resolvePlatform ??
122
+ ((dir, options) => defaultResolvePlatform(dir, options));
123
+ const [command, ...rest] = argv;
124
+ if (command === '--version' || command === '-v') {
125
+ log.info(CLI_VERSION);
126
+ return 0;
127
+ }
128
+ if (command === undefined || command === '--help' || command === '-h') {
129
+ log.info(USAGE);
130
+ return command === undefined ? exitCodeFor('usage') : 0;
131
+ }
132
+ try {
133
+ if (command === 'init') {
134
+ const { values } = parseCliArgs(() => parseArgs({
135
+ args: rest,
136
+ // No `default`: parseArgs then leaves an untyped flag `undefined`,
137
+ // which is how init() tells "the user asked for advisory" from "the
138
+ // user said nothing, keep what the repository already chose". A
139
+ // default here silently demoted every --blocking repo on its next
140
+ // plain re-run.
141
+ options: {
142
+ profile: { type: 'string' },
143
+ vendors: { type: 'string' },
144
+ blocking: { type: 'boolean' },
145
+ 'no-a11y': { type: 'boolean' },
146
+ speckit: { type: 'boolean' },
147
+ 'dry-run': { type: 'boolean' },
148
+ repair: { type: 'boolean' },
149
+ 'adopt-caller': { type: 'boolean' },
150
+ skip: { type: 'string' },
151
+ with: { type: 'string' },
152
+ rung: { type: 'string' },
153
+ },
154
+ allowPositionals: false,
155
+ }));
156
+ const names = (list) => list === undefined
157
+ ? []
158
+ : list
159
+ .split(',')
160
+ .map((name) => name.trim())
161
+ .filter((name) => name !== '');
162
+ // Throws a usage RedlineError on an unknown or non-optional name, before
163
+ // a platform is resolved or a byte is written.
164
+ const selection = capabilitySelection(names(values.skip), names(values.with));
165
+ const menu = { ...selection.menu };
166
+ if (values.blocking !== undefined)
167
+ menu.blockingGate = values.blocking;
168
+ if (values['no-a11y'] !== undefined)
169
+ menu.accessibility = !values['no-a11y'];
170
+ if (values.speckit !== undefined)
171
+ menu.speckit = values.speckit;
172
+ // Same "no default" reasoning as the menu flags above: undefined is how
173
+ // init() tells "nothing typed, keep detection or the recorded
174
+ // selection" from "the caller typed an empty list".
175
+ const vendors = values.vendors !== undefined
176
+ ? values.vendors
177
+ .split(',')
178
+ .map((v) => v.trim())
179
+ .filter((v) => v !== '')
180
+ : undefined;
181
+ if (values.rung !== undefined && !isRung(values.rung)) {
182
+ throw new RedlineError('usage', `--rung must be one of ${RUNGS.join(', ')}, not "${values.rung}"`, 'observe reports and blocks nothing; block-blocker stops a merge on a BLOCKER');
183
+ }
184
+ const dryRun = values['dry-run'] === true;
185
+ const repair = values.repair === true;
186
+ const adoptCaller = values['adopt-caller'] === true;
187
+ // A dry run sends no request, so it must not require a credential —
188
+ // see ResolvePlatformOptions.lazyCredentials. Every other path here
189
+ // resolves one up front, exactly as before.
190
+ const platform = await resolve(cwd, dryRun ? { lazyCredentials: true } : {});
191
+ const report = await init(platform, {
192
+ cwd,
193
+ root,
194
+ ...(values.profile ? { profile: values.profile } : {}),
195
+ ...(vendors ? { vendors } : {}),
196
+ ...(dryRun ? { dryRun: true } : {}),
197
+ ...(repair ? { repair: true } : {}),
198
+ ...(adoptCaller ? { adoptCaller: true } : {}),
199
+ ...(values.rung ? { rung: values.rung } : {}),
200
+ menu,
201
+ capabilities: selection.capabilities,
202
+ });
203
+ log.info(`profile ${report.profile}`);
204
+ if (report.migratedFrom)
205
+ log.info(`migrated from ${report.migratedFrom}`);
206
+ // Printed on every path, settled included: a report that falls silent
207
+ // about what was never attempted cannot be told from one where it broke.
208
+ if (report.optedOut.length > 0)
209
+ log.info(`opted out: ${report.optedOut.join(', ')}`);
210
+ for (const note of report.notes)
211
+ log.info(note);
212
+ if (report.dryRun) {
213
+ log.info('dry run — nothing was written, read or changed on the host');
214
+ if (report.alreadyOnboarded) {
215
+ log.info('already onboarded — no file would change (host settings were not read)');
216
+ return 0;
217
+ }
218
+ for (const file of report.files) {
219
+ log.info(` would ${report.removals.includes(file) ? 'remove' : 'write '} ${file}`);
220
+ }
221
+ for (const step of report.hostPlan)
222
+ log.info(` would apply ${step}`);
223
+ for (const [key, value] of Object.entries(report.menu))
224
+ log.info(` menu ${key}: ${value}`);
225
+ return 0;
226
+ }
227
+ for (const file of report.files) {
228
+ log.info(` ${report.removals.includes(file) ? 'remove' : 'write '} ${file}`);
229
+ }
230
+ for (const outcome of report.outcomes) {
231
+ log.info(` ${outcome.status.padEnd(11)} ${outcome.capability} ${outcome.detail}`);
232
+ }
233
+ if (report.pendingAdmin.length > 0) {
234
+ // On the already-onboarded path this run applied nothing, so the list
235
+ // is what was recorded — not a finding this run made. Saying so is the
236
+ // difference between a status and a claim.
237
+ log.warn(`partially onboarded — an administrator must still enable: ${report.pendingAdmin.join(', ')}` +
238
+ (report.alreadyOnboarded ? ' (as recorded at the last run)' : ''));
239
+ }
240
+ // The host settings and .redline.json are already written — only the
241
+ // review vehicle is missing. That is `failed` (1), not host (4): there
242
+ // is nothing to retry, there is a branch to open a pull request from.
243
+ // The hint does not assert the push succeeded, because the same wrap
244
+ // catches a git step that failed before it: the error line above is the
245
+ // git error itself, which already says where the work was left.
246
+ if (report.pullRequestError !== null) {
247
+ log.error(`could not open the pull request: ${report.pullRequestError}`, `the Redline changes are on branch ${ONBOARD_BRANCH} — push it if it is not already on origin, then open the pull request manually`);
248
+ return exitCodeFor('failed');
249
+ }
250
+ if (report.pullRequest)
251
+ log.info(`pull request: ${report.pullRequest.url}`);
252
+ else if (report.alreadyOnboarded)
253
+ log.info('already onboarded — nothing to change');
254
+ return 0;
255
+ }
256
+ if (command === 'remove') {
257
+ const { values } = parseCliArgs(() => parseArgs({
258
+ args: rest,
259
+ options: { 'dry-run': { type: 'boolean', default: false } },
260
+ allowPositionals: false,
261
+ }));
262
+ const dryRun = values['dry-run'] === true;
263
+ // Same contract as `init --dry-run`: the plan is built from the local
264
+ // checkout, so previewing what backing Redline out would cost needs
265
+ // neither a credential nor a reachable host.
266
+ const platform = await resolve(cwd, dryRun ? { lazyCredentials: true } : {});
267
+ const report = await remove(platform, () => deps.hostWithdrawal?.(cwd) ?? createHostWithdrawal(cwd), { cwd, root, ...(dryRun ? { dryRun: true } : {}) });
268
+ for (const action of report.actions) {
269
+ if (action.kind === 'kept')
270
+ continue;
271
+ const verb = action.kind === 'delete' ? 'remove ' : 'unmerge';
272
+ log.info(` ${report.dryRun ? 'would ' : ''}${verb} ${action.path} — ${action.reason}`);
273
+ }
274
+ // Printed on every run, dry or not. What was left behind is the half of
275
+ // this command's answer an operator has to act on themselves, and a
276
+ // report that only lists deletions reads as if nothing was left.
277
+ for (const action of report.actions) {
278
+ if (action.kind !== 'kept')
279
+ continue;
280
+ log.warn(`left in place ${action.path} — ${action.reason}`);
281
+ }
282
+ for (const note of report.notes)
283
+ log.info(note);
284
+ if (report.dryRun) {
285
+ for (const step of report.hostPlan)
286
+ log.info(` would apply ${step}`);
287
+ log.info('dry run — nothing was written, read or changed on the host');
288
+ return 0;
289
+ }
290
+ for (const outcome of report.outcomes) {
291
+ log.info(` ${outcome.status.padEnd(11)} ${outcome.capability} ${outcome.detail}`);
292
+ }
293
+ if (report.pendingAdmin.length > 0) {
294
+ log.warn(`partially removed — an administrator must still take away: ${report.pendingAdmin.join(', ')}`);
295
+ }
296
+ // The host state is withdrawn and the files are gone from the working
297
+ // tree; only the review vehicle is missing. `failed` (1), not host (4):
298
+ // there is nothing to retry, there is a branch to open a pull request from.
299
+ if (report.pullRequestError !== null) {
300
+ log.error(`could not open the pull request: ${report.pullRequestError}`, `the removal is on branch ${REMOVE_BRANCH} — push it if it is not already on origin, then open the pull request manually`);
301
+ return exitCodeFor('failed');
302
+ }
303
+ if (report.pullRequest)
304
+ log.info(`pull request: ${report.pullRequest.url}`);
305
+ else
306
+ log.info('nothing left to remove from the working tree — no pull request was opened');
307
+ return 0;
308
+ }
309
+ if (command === 'verify') {
310
+ const { values } = parseCliArgs(() => parseArgs({
311
+ args: rest,
312
+ options: { gate: { type: 'boolean', default: false }, repo: { type: 'string' } },
313
+ allowPositionals: false,
314
+ }));
315
+ if (values.repo !== undefined) {
316
+ if (values.gate === true) {
317
+ throw new RedlineError('usage', '--gate publishes this repository\'s merge status and cannot target another repository', 'drop --repo to run the gate, or drop --gate to verify a remote repository');
318
+ }
319
+ const remoteHost = deps.remoteVerifyHost?.() ?? createRemoteVerifyHost();
320
+ const remoteRef = await remoteHost.resolveRef(values.repo);
321
+ const remoteReport = await verifyRemote(remoteHost, remoteRef, {
322
+ root,
323
+ standardsVersion: standardsVersion(root),
324
+ });
325
+ log.info(`${values.repo} (${remoteRef.defaultBranch})`);
326
+ log.report(remoteReport.findings);
327
+ // Same mapping as the local path: one finding means the repository was
328
+ // never onboarded, which is a different thing to tell an operator than
329
+ // onboarded-and-drifted.
330
+ const never = !remoteReport.ok && remoteReport.findings.length === 1;
331
+ return remoteReport.ok ? 0 : never ? exitCodeFor('usage') : exitCodeFor('failed');
332
+ }
333
+ // resolve is passed unevaluated: verify() must be able to report "not
334
+ // onboarded" without a host credential — see cli/commands/verify.ts.
335
+ const report = await verify(() => resolve(cwd), { cwd, root, gate: values.gate === true });
336
+ log.report(report.findings);
337
+ // verify() short-circuits to exactly one finding when .redline.json is
338
+ // missing or corrupt (see cli/commands/verify.ts), precisely so this
339
+ // mapping can tell "never onboarded" (usage, 2) apart from "onboarded
340
+ // but wrong" (failed, 1) without verify() itself owning exit codes.
341
+ const notOnboarded = !report.ok && report.findings.length === 1;
342
+ const exitCode = report.ok ? 0 : notOnboarded ? exitCodeFor('usage') : exitCodeFor('failed');
343
+ if (values.gate === true) {
344
+ if (report.ok)
345
+ log.info('Redline gate passed.');
346
+ else if (notOnboarded)
347
+ log.info('Redline gate: repository not onboarded — run redline init.');
348
+ else
349
+ log.info('Redline gate failed.');
350
+ }
351
+ return exitCode;
352
+ }
353
+ if (command === 'review') {
354
+ const { values } = parseCliArgs(() => parseArgs({
355
+ args: rest,
356
+ options: {
357
+ staged: { type: 'boolean', default: false },
358
+ 'diff-file': { type: 'string' },
359
+ base: { type: 'string' },
360
+ profile: { type: 'string' },
361
+ engine: { type: 'string', default: 'embedded' },
362
+ provider: { type: 'string', default: 'openai' },
363
+ model: { type: 'string' },
364
+ 'base-url': { type: 'string' },
365
+ },
366
+ allowPositionals: false,
367
+ }));
368
+ let engine = embedded;
369
+ if (values.engine === 'api') {
370
+ if (!values.model) {
371
+ throw new RedlineError('usage', '--engine api needs --model <name>', 'a model baked into the tool is one nobody can change when it is deprecated');
372
+ }
373
+ // Not a silent fallback: `--provider anthropc` used to become openai and
374
+ // send the diff to a local endpoint that was not running, reporting a
375
+ // connection error for what was a typo.
376
+ if (values.provider !== 'openai' && values.provider !== 'anthropic') {
377
+ throw new RedlineError('usage', `--provider must be openai or anthropic, not "${values.provider}"`, 'openai covers every OpenAI-compatible endpoint, including a local one');
378
+ }
379
+ const provider = values.provider;
380
+ engine = createApiEngine({
381
+ provider,
382
+ model: values.model,
383
+ ...(values['base-url'] ? { baseUrl: values['base-url'] } : {}),
384
+ });
385
+ }
386
+ else if (values.engine !== 'embedded') {
387
+ throw new RedlineError('usage', `unknown engine "${values.engine}" — use embedded or api`);
388
+ }
389
+ const report = await review(engine, {
390
+ cwd,
391
+ root,
392
+ ...(values.profile ? { profile: values.profile } : {}),
393
+ ...(values.base ? { base: values.base } : {}),
394
+ source: values['diff-file']
395
+ ? { kind: 'diff-file', path: values['diff-file'] }
396
+ : values.staged
397
+ ? { kind: 'staged' }
398
+ : { kind: 'worktree' },
399
+ });
400
+ log.info(`profile ${report.scope.profile} — rules in scope: core` +
401
+ (report.scope.stacks.length ? `, ${report.scope.stacks.join(', ')}` : ''));
402
+ // A file no stack covers is a gap in the standard. Reviewing it against
403
+ // core alone and saying nothing hides that.
404
+ if (report.scope.uncovered.length > 0) {
405
+ log.warn(`no stack covers ${report.scope.uncovered.length} changed file(s), so only the core rules ` +
406
+ `applied to them: ${report.scope.uncovered.slice(0, 5).join(', ')}` +
407
+ (report.scope.uncovered.length > 5 ? ', and more' : ''));
408
+ }
409
+ if (report.prompt !== null) {
410
+ log.info('');
411
+ log.info(report.prompt);
412
+ log.info('');
413
+ log.info(report.note ?? '');
414
+ return 0;
415
+ }
416
+ for (const finding of report.findings) {
417
+ log.info(`${finding.file}:${finding.line}`);
418
+ log.info(` ${renderFinding(finding)}`);
419
+ }
420
+ for (const { reason } of report.rejected) {
421
+ log.warn(`discarded a finding from the model: ${reason}`);
422
+ }
423
+ log.info(report.findings.length === 0
424
+ ? 'no findings — an empty review is a valid review'
425
+ : `${report.findings.length} finding(s)`);
426
+ // Said out loud on every run. A local review is opt-in and enforces
427
+ // nothing; the pull request review remains the system of record.
428
+ log.info('local review — not recorded, and not counted in rule-tuning telemetry');
429
+ // Always 0. This is a pre-flight convenience, and a non-zero exit would
430
+ // invite someone to wire it into CI as a second gate, where it would
431
+ // enforce nothing while looking like it did.
432
+ return 0;
433
+ }
434
+ if (command === 'policy') {
435
+ const { values } = parseCliArgs(() => parseArgs({
436
+ args: rest,
437
+ options: { 'diff-file': { type: 'string' }, 'fail-on': { type: 'string' } },
438
+ allowPositionals: false,
439
+ }));
440
+ const diffFile = values['diff-file'];
441
+ if (!diffFile)
442
+ throw new RedlineError('usage', 'redline policy needs --diff-file <path>');
443
+ const failOn = values['fail-on'];
444
+ if (failOn !== undefined && !isSeverity(failOn)) {
445
+ throw new RedlineError('usage', `--fail-on must be BLOCKER, HIGH or SUGGESTION, not "${failOn}"`);
446
+ }
447
+ const report = policy({ root, diffFile, ...(failOn ? { failOn } : {}) });
448
+ for (const finding of report.findings) {
449
+ log.info(`${finding.file}:${finding.line}`);
450
+ log.info(` ${formatFinding(finding)}`);
451
+ }
452
+ // Said on every run, including the clean one. A reviewer has to be able to
453
+ // tell "checked and clean" from "not checked", and silence looks the same
454
+ // as both.
455
+ log.info(report.findings.length === 0
456
+ ? `no deterministic findings — ${report.evaluated.length} rule(s) evaluated with no model call`
457
+ : `${report.findings.length} finding(s) from ${report.evaluated.length} deterministic rule(s)`);
458
+ for (const id of report.unimplemented) {
459
+ log.warn(`${id} is classified deterministic but has no check — it is enforced by nobody`);
460
+ }
461
+ return report.ok ? 0 : exitCodeFor('failed');
462
+ }
463
+ if (command === 'exempt') {
464
+ const { values } = parseCliArgs(() => parseArgs({
465
+ args: rest,
466
+ options: { 'body-file': { type: 'string' }, scope: { type: 'string' } },
467
+ allowPositionals: false,
468
+ }));
469
+ const bodyFile = values['body-file'];
470
+ if (!bodyFile) {
471
+ throw new RedlineError('usage', 'redline exempt needs --body-file <path>');
472
+ }
473
+ const report = exempt({
474
+ bodyFile,
475
+ ...(values.scope ? { scope: values.scope } : {}),
476
+ });
477
+ for (const message of report.messages) {
478
+ if (report.applies)
479
+ log.info(message);
480
+ else
481
+ log.warn(message);
482
+ }
483
+ // Exit 1, not 2: a pull request without a valid exemption is a normal,
484
+ // expected answer the gate acts on — not the caller misusing the command.
485
+ return report.applies ? 0 : exitCodeFor('failed');
486
+ }
487
+ if (command === 'sync') {
488
+ const { values } = parseCliArgs(() => parseArgs({
489
+ args: rest,
490
+ options: {
491
+ 'dry-run': { type: 'boolean', default: false },
492
+ repo: { type: 'string' },
493
+ force: { type: 'boolean', default: false },
494
+ },
495
+ allowPositionals: false,
496
+ }));
497
+ const dryRun = values['dry-run'] === true;
498
+ // A dry run reads the register and the target's own files but never
499
+ // writes, so it still needs a read credential — unlike `init --dry-run`,
500
+ // which contacts no host at all. Saying so beats a confusing 403.
501
+ const report = await sync(deps.syncHost?.() ?? createSyncHost(), {
502
+ root,
503
+ cwd,
504
+ ...(values.repo ? { repo: values.repo } : {}),
505
+ ...(values.force ? { force: true } : {}),
506
+ ...(dryRun ? { dryRun: true } : {}),
507
+ });
508
+ log.info(`standards v${report.plan.standardsVersion} — ${report.plan.targets.length} target(s), ${report.plan.skipped.length} skipped`);
509
+ for (const { repo, outcome } of report.results) {
510
+ if (outcome.kind === 'failed')
511
+ log.error(` ${repo}: ${outcome.detail}`);
512
+ else if (outcome.kind === 'current')
513
+ log.info(` ${repo}: already current`);
514
+ else if (outcome.kind === 'not-onboarded')
515
+ log.warn(` ${repo}: no .redline.json — not onboarded`);
516
+ else if (outcome.kind === 'stale-artifacts') {
517
+ // Content is current, but the target still carries files this profile
518
+ // no longer renders. Sync will not delete another repository's files;
519
+ // saying nothing reported it as healthy, which is what silent drift is.
520
+ log.warn(` ${repo}: current, but still carrying ${outcome.paths.join(', ')} — ` +
521
+ 'a stack or vendor left this profile. Re-run redline init there to clear them');
522
+ }
523
+ else
524
+ log.info(` ${repo}: ${outcome.kind} ${outcome.url}`);
525
+ }
526
+ if (dryRun)
527
+ log.info('dry run — no branch was pushed and no pull request was opened');
528
+ // A partially failed estate run is a failure. Reporting 0 because most
529
+ // repositories succeeded is how a distribution quietly stops covering the
530
+ // ones it cannot reach.
531
+ return report.failures > 0 ? exitCodeFor('failed') : 0;
532
+ }
533
+ if (command === 'metrics' || command === 'registry') {
534
+ // The subcommand is a positional for `metrics` and absent for `registry`,
535
+ // so the flags are whatever follows it.
536
+ const [sub, ...flagArgs] = command === 'metrics' ? rest : ['registry', ...rest];
537
+ if (command === 'metrics' && (sub === undefined || sub === '--help' || sub === '-h')) {
538
+ log.info('redline metrics <command>');
539
+ log.info('');
540
+ for (const [name, spec] of Object.entries(METRICS_COMMANDS)) {
541
+ log.info(` ${name.padEnd(14)} ${spec.summary}`);
542
+ }
543
+ log.info('');
544
+ log.info('Run `redline metrics <command> --help` for its flags.');
545
+ return sub === undefined ? exitCodeFor('usage') : 0;
546
+ }
547
+ const spec = specFor(sub);
548
+ if (flagArgs.includes('--help') || flagArgs.includes('-h')) {
549
+ log.info(helpFor(command === 'registry' ? 'registry' : `metrics ${sub}`, spec));
550
+ return 0;
551
+ }
552
+ // Built from the same table that validates and documents them, so a flag
553
+ // cannot exist in one of the three and not the others.
554
+ const options = Object.fromEntries(Object.entries(spec.options).map(([flag, option]) => [
555
+ flag,
556
+ { type: option.type === 'boolean' ? 'boolean' : 'string' },
557
+ ]));
558
+ const { values } = parseCliArgs(() => parseArgs({ args: flagArgs, options, allowPositionals: false }));
559
+ await runMetrics(sub, { root, flags: values, ...(deps.loadRunner ? { load: deps.loadRunner } : {}) });
560
+ return 0;
561
+ }
562
+ log.error(`unknown command "${command}"`, 'run: redline --help');
563
+ return exitCodeFor('usage');
564
+ }
565
+ catch (error) {
566
+ if (isRedlineError(error)) {
567
+ log.error(error.message, error.hint);
568
+ return error.exitCode;
569
+ }
570
+ // Not a RedlineError: parseArgs failures are already converted to a
571
+ // usage RedlineError at their own call site (parseCliArgs above), and
572
+ // resolveProfile (unknown profile) and render() (unknown vendor) in
573
+ // cli/render/ throw RedlineError('usage', ...) as of Task 21. So
574
+ // whatever reaches here is a genuine internal defect — a TypeError, a
575
+ // null dereference, an unexpected throw from anywhere — not the user's
576
+ // mistake. Exit 2 would tell the user to check their flags when the
577
+ // tool itself is broken, which sends CI readers debugging the wrong
578
+ // thing. There is no sixth exit code to add for "internal defect", so
579
+ // this reuses exit 4 (host/network) as the least-wrong of the five
580
+ // published codes: at least it does not accuse the user.
581
+ log.error(`redline failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
582
+ return exitCodeFor('host');
583
+ }
584
+ }
585
+ // npm installs a bin as a symlink on POSIX, so process.argv[1] is the symlink
586
+ // path while import.meta.url is the real file. A textual comparison is false
587
+ // there, run() never fires, and the process exits 0 having printed nothing —
588
+ // which would make `redline verify --gate` pass unconditionally in CI. Compare
589
+ // resolved real paths instead. realpathSync throws if argv[1] is not a real
590
+ // path (an eval/stdin entry point), which is not this module being executed.
591
+ function isDirectlyExecuted() {
592
+ const entry = process.argv[1];
593
+ if (entry === undefined)
594
+ return false;
595
+ try {
596
+ return realpathSync(entry) === fileURLToPath(import.meta.url);
597
+ }
598
+ catch {
599
+ return false;
600
+ }
601
+ }
602
+ if (isDirectlyExecuted()) {
603
+ process.exitCode = await run(process.argv.slice(2));
604
+ }
605
+ //# sourceMappingURL=redline.js.map