@archora/forge-pro 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +113 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +826 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Archora Forge Intelligence Proprietary Source-Available License (Paid-Use-Only)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksandr Kotov (the "Licensor"). All rights reserved.
|
|
4
|
+
|
|
5
|
+
This license governs the @archora/forge-pro package — "Forge Intelligence",
|
|
6
|
+
the commercial team/CI layer of Archora Forge (the "Software"). The free
|
|
7
|
+
Archora Forge generator packages are licensed separately under the MIT License
|
|
8
|
+
(see the LICENSE file at the root of this repository) and are not subject to
|
|
9
|
+
the terms below.
|
|
10
|
+
|
|
11
|
+
The source code and any other materials of the Software are proprietary and
|
|
12
|
+
confidential to the Licensor. They are published in source-available form for
|
|
13
|
+
the sole purpose of allowing prospective licensees to inspect and evaluate the
|
|
14
|
+
Software before purchasing a commercial license.
|
|
15
|
+
|
|
16
|
+
This Software is NOT open source. The publication of this repository on a
|
|
17
|
+
public hosting platform does NOT constitute a grant of any open-source or
|
|
18
|
+
free-software license, and does NOT grant any rights beyond those expressly
|
|
19
|
+
stated below.
|
|
20
|
+
|
|
21
|
+
1. Limited Permission Granted Without a Paid License
|
|
22
|
+
|
|
23
|
+
Without entering into a separate written commercial license with the
|
|
24
|
+
Licensor, you are permitted to do ONLY the following with the Software:
|
|
25
|
+
|
|
26
|
+
(a) view and read the source code via the hosting platform on which
|
|
27
|
+
it is published;
|
|
28
|
+
(b) clone or download a copy strictly for the purpose of personally
|
|
29
|
+
evaluating whether to obtain a paid commercial license, for a
|
|
30
|
+
reasonable evaluation period not exceeding thirty (30) days; and
|
|
31
|
+
(c) discuss the Software publicly (e.g. cite, link, or quote short
|
|
32
|
+
excerpts for commentary, criticism, news reporting, teaching,
|
|
33
|
+
scholarship, or research) consistent with applicable fair use
|
|
34
|
+
or fair dealing law.
|
|
35
|
+
|
|
36
|
+
No other rights are granted. In particular, and without limitation,
|
|
37
|
+
the following are NOT permitted without a separate paid commercial
|
|
38
|
+
license signed by the Licensor:
|
|
39
|
+
|
|
40
|
+
- any use of the Software, in whole or in part, in any project,
|
|
41
|
+
product, service, library, internal tool, script, pipeline,
|
|
42
|
+
build, CI job, or deliverable, whether commercial or
|
|
43
|
+
non-commercial, paid or unpaid, personal or organizational,
|
|
44
|
+
for-profit or not-for-profit, including hobby and educational
|
|
45
|
+
projects;
|
|
46
|
+
- execution of the Software for any purpose other than short-term
|
|
47
|
+
evaluation by you personally as described in clause 1(b);
|
|
48
|
+
- copying, modifying, merging, publishing, sublicensing,
|
|
49
|
+
distributing, selling, hosting, or offering the Software (or any
|
|
50
|
+
derivative work) as a service to any third party;
|
|
51
|
+
- removing or altering any copyright, trademark, license, or
|
|
52
|
+
attribution notices contained in the Software or in its outputs;
|
|
53
|
+
- using the Software, its source code, or its outputs to train,
|
|
54
|
+
fine-tune, evaluate, benchmark, or otherwise develop
|
|
55
|
+
machine-learning or artificial-intelligence models, datasets, or
|
|
56
|
+
systems;
|
|
57
|
+
- reverse-engineering the Software for any purpose other than
|
|
58
|
+
evaluation as described in clause 1(b), to the extent such
|
|
59
|
+
restriction is permitted by applicable law.
|
|
60
|
+
|
|
61
|
+
2. No Implied License
|
|
62
|
+
|
|
63
|
+
No license is granted by implication, estoppel, exhaustion, course of
|
|
64
|
+
dealing, or otherwise. Public availability of this repository does
|
|
65
|
+
not change this.
|
|
66
|
+
|
|
67
|
+
3. Commercial License Required
|
|
68
|
+
|
|
69
|
+
To use the Software for any purpose beyond the limited evaluation
|
|
70
|
+
described in Section 1, you must first obtain a paid commercial
|
|
71
|
+
license from the Licensor. Pricing and terms are available on
|
|
72
|
+
request — see COMMERCIAL-LICENSE.md at the root of this repository.
|
|
73
|
+
|
|
74
|
+
4. Trademarks
|
|
75
|
+
|
|
76
|
+
"Archora" and "Archora Forge" and any associated names, logos, and
|
|
77
|
+
brands are trademarks of the Licensor. No trademark rights are
|
|
78
|
+
granted by this license. Forks, modifications, or derivative works
|
|
79
|
+
(where permitted under a separate commercial license) may not use
|
|
80
|
+
these marks without the Licensor's prior written consent.
|
|
81
|
+
|
|
82
|
+
5. Term and Termination
|
|
83
|
+
|
|
84
|
+
Your permission under Section 1 terminates automatically and
|
|
85
|
+
immediately upon any breach of this license, or upon written notice
|
|
86
|
+
from the Licensor. Upon termination you must delete all copies of
|
|
87
|
+
the Software in your possession or control, except for archival
|
|
88
|
+
copies you are required by law to retain.
|
|
89
|
+
|
|
90
|
+
6. No Warranty
|
|
91
|
+
|
|
92
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
93
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
94
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
|
|
95
|
+
NON-INFRINGEMENT.
|
|
96
|
+
|
|
97
|
+
7. Limitation of Liability
|
|
98
|
+
|
|
99
|
+
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL
|
|
100
|
+
THE LICENSOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
101
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN CONNECTION
|
|
102
|
+
WITH THE SOFTWARE OR THIS LICENSE, EVEN IF ADVISED OF THE POSSIBILITY
|
|
103
|
+
OF SUCH DAMAGES.
|
|
104
|
+
|
|
105
|
+
8. Governing Law
|
|
106
|
+
|
|
107
|
+
This license shall be governed by and construed in accordance with
|
|
108
|
+
the laws applicable at the Licensor's place of residence, without
|
|
109
|
+
regard to its conflict-of-laws principles.
|
|
110
|
+
|
|
111
|
+
For commercial licensing inquiries, contact:
|
|
112
|
+
Email: akotov@archora.dev
|
|
113
|
+
Telegram: @akotofff
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CAC } from 'cac';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Forge Intelligence (Pro) command tier.
|
|
5
|
+
*
|
|
6
|
+
* Everything registered here is the paid, team/CI layer that operates on top
|
|
7
|
+
* of the free generator: impact reporting, the CI readiness gate, adoption
|
|
8
|
+
* audits and pilot reporting. These commands are license-gated at runtime via
|
|
9
|
+
* `requireCommercialLicense` inside each command action.
|
|
10
|
+
*
|
|
11
|
+
* This module is the single extraction seam: moving it (and the command files
|
|
12
|
+
* it imports) into a standalone `@archora/forge-pro` package later requires no
|
|
13
|
+
* changes to the free CLI other than swapping this import for a dynamic load.
|
|
14
|
+
*/
|
|
15
|
+
declare function registerProCommands(cli: CAC): void;
|
|
16
|
+
|
|
17
|
+
export { registerProCommands };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
// src/commands/audit.command.ts
|
|
2
|
+
import {
|
|
3
|
+
logger,
|
|
4
|
+
requireCommercialLicense,
|
|
5
|
+
runAuditPackage
|
|
6
|
+
} from "@archora/forge-cli/internal";
|
|
7
|
+
function registerAuditCommand(cli) {
|
|
8
|
+
cli.command("audit [schema]", "Create a local frontend API adoption package").option("--config <path>", "Use a specific Archora Forge config file").option(
|
|
9
|
+
"--schema-header <header>",
|
|
10
|
+
'Add a remote schema request header as "name:value" or "name=value"'
|
|
11
|
+
).option("--out <path>", "Output directory for the audit package").option("--json", "Print the audit JSON payload").option("--skip-typecheck", "Skip generated-output TypeScript typecheck").option("--min-health-score <score>", "Use this health score as the audit acceptance threshold").action(async (schema, options) => {
|
|
12
|
+
try {
|
|
13
|
+
await requireCommercialLicense("audit");
|
|
14
|
+
const result = await runAuditPackage(schema, options);
|
|
15
|
+
if (options.json) {
|
|
16
|
+
console.log(JSON.stringify(result.payload, null, 2));
|
|
17
|
+
} else {
|
|
18
|
+
logger.title();
|
|
19
|
+
logger.line(`Audit package: ${result.outDir}`);
|
|
20
|
+
logger.line(`Schemas: ${result.entries.length}`);
|
|
21
|
+
logger.line(`Resources: ${result.payload.resources}`);
|
|
22
|
+
logger.line(`Generated files: ${result.payload.generatedFiles}`);
|
|
23
|
+
logger.line(`Typecheck: ${result.payload.typecheck.status}`);
|
|
24
|
+
logger.line(`Decision: ${result.payload.readiness.decision}`);
|
|
25
|
+
}
|
|
26
|
+
process.exitCode = result.payload.ok ? 0 : 1;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (options.json) {
|
|
29
|
+
console.log(
|
|
30
|
+
JSON.stringify(
|
|
31
|
+
{ ok: false, error: error instanceof Error ? error.message : String(error) },
|
|
32
|
+
null,
|
|
33
|
+
2
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
} else {
|
|
37
|
+
logger.error(error instanceof Error ? error.message : String(error));
|
|
38
|
+
}
|
|
39
|
+
process.exitCode = 2;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/commands/check.command.ts
|
|
45
|
+
import {
|
|
46
|
+
calculateSchemaHealth,
|
|
47
|
+
calculateDrift,
|
|
48
|
+
collectDiagnostics,
|
|
49
|
+
createGenerationPlan,
|
|
50
|
+
createSchemaCoverageMatrix,
|
|
51
|
+
detectResources,
|
|
52
|
+
mergeSchemaCoverageMatrices,
|
|
53
|
+
normalizeOpenApi,
|
|
54
|
+
parseOpenApi,
|
|
55
|
+
summarizeGeneratorMetadata,
|
|
56
|
+
summarizeFilePlan
|
|
57
|
+
} from "@archora/forge-core";
|
|
58
|
+
import { resolveQueryComposables } from "@archora/forge-adapters";
|
|
59
|
+
import { loadCliConfigSet, createHtmlReport, requireCommercialLicense as requireCommercialLicense2, writeReportFile, logger as logger2 } from "@archora/forge-cli/internal";
|
|
60
|
+
function registerCheckCommand(cli) {
|
|
61
|
+
cli.command("check [schema]", "Check generated output drift and diagnostics without writing files").option("--config <path>", "Use a specific Archora Forge config file").option("--schema-header <header>", 'Add a remote schema request header as "name:value" or "name=value"').option("--json", "Print machine-readable JSON").option("--report <format>", "Print a report format: json, markdown or html").option("--report-file <path>", "Write the selected check report to a file").option("--min-health-score <score>", "Fail when the OpenAPI health score is below this value").action(async (schema, options) => {
|
|
62
|
+
try {
|
|
63
|
+
await requireCommercialLicense2("check");
|
|
64
|
+
assertReportFormat(options.report);
|
|
65
|
+
const minHealthScore = parseMinHealthScore(options.minHealthScore);
|
|
66
|
+
const checks = await Promise.all((await loadCliConfigSet(schema, options)).map((loaded) => runCheck(loaded, { minHealthScore })));
|
|
67
|
+
const primary = checks[0];
|
|
68
|
+
if (!primary) throw new Error("No OpenAPI schema inputs were resolved.");
|
|
69
|
+
const drift = checks.flatMap((check) => check.drift);
|
|
70
|
+
const diagnostics = checks.flatMap((check) => check.diagnostics);
|
|
71
|
+
const failedChecks = [...new Set(checks.flatMap((check) => check.failedChecks))];
|
|
72
|
+
const generator = summarizeCheckGeneratorMetadata(checks.map((check) => check.generator));
|
|
73
|
+
const coverage = mergeSchemaCoverageMatrices(checks.map((check) => check.coverage));
|
|
74
|
+
const ok = failedChecks.length === 0;
|
|
75
|
+
const healthScore = Math.min(...checks.map((check) => check.healthScore));
|
|
76
|
+
const summary = {
|
|
77
|
+
healthScore,
|
|
78
|
+
resources: checks.reduce((total, check) => total + check.resources, 0),
|
|
79
|
+
generatedFiles: checks.reduce((total, check) => total + check.generatedFiles, 0),
|
|
80
|
+
protectedFiles: checks.reduce((total, check) => total + check.protectedFiles, 0),
|
|
81
|
+
diagnostics: diagnostics.length,
|
|
82
|
+
drift: drift.length,
|
|
83
|
+
failedChecks: failedChecks.length
|
|
84
|
+
};
|
|
85
|
+
const payload = {
|
|
86
|
+
ok,
|
|
87
|
+
schema: primary.schema,
|
|
88
|
+
schemas: checks.map((check) => ({
|
|
89
|
+
name: check.name,
|
|
90
|
+
schema: check.schema,
|
|
91
|
+
configPath: check.configPath,
|
|
92
|
+
healthScore: check.healthScore,
|
|
93
|
+
resources: check.resources,
|
|
94
|
+
generatedFiles: check.generatedFiles,
|
|
95
|
+
protectedFiles: check.protectedFiles,
|
|
96
|
+
driftCount: check.drift.length,
|
|
97
|
+
diagnosticsCount: check.diagnostics.length,
|
|
98
|
+
failedChecks: check.failedChecks,
|
|
99
|
+
generator: check.generator,
|
|
100
|
+
coverage: check.coverage
|
|
101
|
+
})),
|
|
102
|
+
healthScore,
|
|
103
|
+
resources: summary.resources,
|
|
104
|
+
generatedFiles: summary.generatedFiles,
|
|
105
|
+
protectedFiles: summary.protectedFiles,
|
|
106
|
+
failedChecks,
|
|
107
|
+
generator,
|
|
108
|
+
coverage,
|
|
109
|
+
readiness: createReadinessSummary({ summary, failedChecks, drift, diagnostics }),
|
|
110
|
+
drift,
|
|
111
|
+
diagnostics
|
|
112
|
+
};
|
|
113
|
+
if (options.reportFile) {
|
|
114
|
+
const reportPath = await writeReportFile(
|
|
115
|
+
options.reportFile,
|
|
116
|
+
createCheckReport(payload, options)
|
|
117
|
+
);
|
|
118
|
+
console.log(`Report written: ${reportPath}`);
|
|
119
|
+
} else if (options.json || options.report === "json") {
|
|
120
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
121
|
+
} else if (options.report === "markdown") {
|
|
122
|
+
console.log(createMarkdownReport(payload));
|
|
123
|
+
} else if (options.report === "html") {
|
|
124
|
+
console.log(createHtmlReport("Archora Forge Check", payload));
|
|
125
|
+
} else {
|
|
126
|
+
logger2.title();
|
|
127
|
+
logger2.line(checks.length === 1 ? `Schema: ${primary.schema}` : `Schemas: ${checks.length}`);
|
|
128
|
+
logger2.line(`Resources: ${payload.resources}`);
|
|
129
|
+
logger2.line(`Generated files: ${payload.generatedFiles}`);
|
|
130
|
+
logger2.line(`Protected files: ${payload.protectedFiles}`);
|
|
131
|
+
logger2.line(`Failed checks: ${payload.failedChecks.length > 0 ? payload.failedChecks.join(", ") : "none"}`);
|
|
132
|
+
logger2.line("");
|
|
133
|
+
logger2.line("Drift:");
|
|
134
|
+
if (drift.length === 0) logger2.success("Generated output is up to date.");
|
|
135
|
+
for (const entry of drift) logger2.warn(`${entry.path} is ${entry.kind}`);
|
|
136
|
+
logger2.line("");
|
|
137
|
+
logger2.line("Diagnostics:");
|
|
138
|
+
if (diagnostics.length === 0) logger2.success("No diagnostics.");
|
|
139
|
+
for (const diagnostic of diagnostics.slice(0, 20)) logger2.warn(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}`);
|
|
140
|
+
logger2.line("");
|
|
141
|
+
logger2.line(ok ? "Result: generated output is up to date." : "Result: generated output is not up to date.");
|
|
142
|
+
}
|
|
143
|
+
process.exitCode = ok ? 0 : 1;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (options.json) {
|
|
146
|
+
console.log(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
|
|
147
|
+
} else {
|
|
148
|
+
logger2.error(error instanceof Error ? error.message : String(error));
|
|
149
|
+
}
|
|
150
|
+
process.exitCode = 2;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function assertReportFormat(report) {
|
|
155
|
+
if (report !== void 0 && report !== "json" && report !== "markdown" && report !== "html") {
|
|
156
|
+
throw new Error(`Invalid report format "${report}". Expected "json", "markdown" or "html".`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function createCheckReport(payload, options) {
|
|
160
|
+
if (options.report === "html") return createHtmlReport("Archora Forge Check", payload);
|
|
161
|
+
if (options.json || options.report === "json") return JSON.stringify(payload, null, 2);
|
|
162
|
+
return createMarkdownReport(payload);
|
|
163
|
+
}
|
|
164
|
+
function parseMinHealthScore(value) {
|
|
165
|
+
if (value === void 0) return void 0;
|
|
166
|
+
const parsed = Number(value);
|
|
167
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) {
|
|
168
|
+
throw new Error(`Invalid minimum health score "${value}". Expected a number between 0 and 100.`);
|
|
169
|
+
}
|
|
170
|
+
return parsed;
|
|
171
|
+
}
|
|
172
|
+
async function runCheck(loaded, options = {}) {
|
|
173
|
+
const document = await parseOpenApi(loaded.schema, loaded.config.schemaRequest);
|
|
174
|
+
const normalized = normalizeOpenApi(document);
|
|
175
|
+
const resources = detectResources(normalized.operations).filter((resource) => loaded.config.resources[resource.name]?.enabled !== false);
|
|
176
|
+
const plan = await createGenerationPlan({ config: loaded.config, normalized, resources, cwd: loaded.cwd, composables: resolveQueryComposables(loaded.config.target.query) });
|
|
177
|
+
const summary = summarizeFilePlan(plan.files);
|
|
178
|
+
const drift = await calculateDrift(plan.files, { cwd: loaded.cwd });
|
|
179
|
+
const generator = await summarizeGeneratorMetadata(plan.files, { cwd: loaded.cwd });
|
|
180
|
+
const diagnostics = collectDiagnostics(normalized);
|
|
181
|
+
const coverage = createSchemaCoverageMatrix(normalized, diagnostics);
|
|
182
|
+
const healthScore = calculateSchemaHealth(normalized).score;
|
|
183
|
+
const failedChecks = evaluateFailedChecks({
|
|
184
|
+
drift,
|
|
185
|
+
diagnostics,
|
|
186
|
+
healthScore,
|
|
187
|
+
ci: { ...loaded.config.ci, minHealthScore: options.minHealthScore ?? loaded.config.ci.minHealthScore }
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
name: loaded.name ?? "default",
|
|
191
|
+
schema: loaded.schema,
|
|
192
|
+
configPath: loaded.configPath,
|
|
193
|
+
healthScore,
|
|
194
|
+
resources: resources.length,
|
|
195
|
+
generatedFiles: plan.files.filter((file) => file.kind === "generated").length,
|
|
196
|
+
protectedFiles: summary.protected,
|
|
197
|
+
generator,
|
|
198
|
+
coverage,
|
|
199
|
+
drift,
|
|
200
|
+
diagnostics,
|
|
201
|
+
failedChecks
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function createMarkdownReport(payload) {
|
|
205
|
+
const drift = payload.drift.length === 0 ? "- No drift detected." : payload.drift.map((entry) => `- \`${entry.path}\` is ${entry.kind}`).join("\n");
|
|
206
|
+
const diagnostics = payload.diagnostics.length === 0 ? "- No diagnostics." : payload.diagnostics.map((diagnostic) => `- ${diagnostic.severity} \`${diagnostic.code}\`: ${diagnostic.message}`).join("\n");
|
|
207
|
+
const readiness = payload.readiness ? `## Pilot Readiness
|
|
208
|
+
|
|
209
|
+
Readiness: ${payload.readiness.status}
|
|
210
|
+
|
|
211
|
+
${payload.readiness.gate ? `Gate: ${payload.readiness.gate.result}
|
|
212
|
+
|
|
213
|
+
Recommended CI mode: ${payload.readiness.gate.recommendedCiMode}
|
|
214
|
+
|
|
215
|
+
Gate reason: ${payload.readiness.gate.reason}
|
|
216
|
+
` : ""}
|
|
217
|
+
|
|
218
|
+
Decision: ${payload.readiness.decision}
|
|
219
|
+
|
|
220
|
+
Blockers:
|
|
221
|
+
${formatMarkdownList(payload.readiness.blockers, "No blockers.")}
|
|
222
|
+
|
|
223
|
+
Warnings:
|
|
224
|
+
${formatMarkdownList(payload.readiness.warnings, "No warnings.")}
|
|
225
|
+
|
|
226
|
+
Next actions:
|
|
227
|
+
${formatMarkdownList(payload.readiness.nextActions, "No action required.")}
|
|
228
|
+
|
|
229
|
+
` : "";
|
|
230
|
+
const generator = payload.generator ? formatGeneratorMarkdown(payload.generator) : "";
|
|
231
|
+
return `# Archora Forge Check
|
|
232
|
+
|
|
233
|
+
Status: ${payload.ok ? "passed" : "failed"}
|
|
234
|
+
|
|
235
|
+
Failed checks: ${payload.failedChecks.length > 0 ? payload.failedChecks.join(", ") : "none"}
|
|
236
|
+
|
|
237
|
+
Health score: ${payload.healthScore ?? "n/a"}
|
|
238
|
+
|
|
239
|
+
${readiness}
|
|
240
|
+
${generator}
|
|
241
|
+
## Drift
|
|
242
|
+
|
|
243
|
+
${drift}
|
|
244
|
+
|
|
245
|
+
## Diagnostics
|
|
246
|
+
|
|
247
|
+
${diagnostics}
|
|
248
|
+
|
|
249
|
+
## Suggested action
|
|
250
|
+
|
|
251
|
+
${payload.ok ? "No action required." : "Run `archora-forge generate <schema>` and commit generated changes, or fix reported OpenAPI diagnostics."}
|
|
252
|
+
`;
|
|
253
|
+
}
|
|
254
|
+
function summarizeCheckGeneratorMetadata(summaries) {
|
|
255
|
+
const files = {
|
|
256
|
+
total: summaries.reduce((total, summary) => total + summary.files.total, 0),
|
|
257
|
+
missingMetadata: summaries.flatMap((summary) => summary.files.missingMetadata),
|
|
258
|
+
versionMismatches: summaries.flatMap((summary) => summary.files.versionMismatches),
|
|
259
|
+
schemaHashMismatches: summaries.flatMap((summary) => summary.files.schemaHashMismatches),
|
|
260
|
+
configHashMismatches: summaries.flatMap((summary) => summary.files.configHashMismatches)
|
|
261
|
+
};
|
|
262
|
+
const mismatchCount = files.versionMismatches.length + files.schemaHashMismatches.length + files.configHashMismatches.length;
|
|
263
|
+
return {
|
|
264
|
+
status: mismatchCount > 0 ? "mismatch" : files.missingMetadata.length > 0 ? "missing-metadata" : "current",
|
|
265
|
+
version: summaries[0]?.version ?? "unknown",
|
|
266
|
+
files
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function formatGeneratorMarkdown(summary) {
|
|
270
|
+
return `## Generator
|
|
271
|
+
|
|
272
|
+
Status: ${summary.status}
|
|
273
|
+
|
|
274
|
+
Version: ${summary.version}
|
|
275
|
+
|
|
276
|
+
Generated files: ${summary.files.total}
|
|
277
|
+
|
|
278
|
+
Missing metadata: ${summary.files.missingMetadata.length}
|
|
279
|
+
|
|
280
|
+
Version mismatches: ${summary.files.versionMismatches.length}
|
|
281
|
+
|
|
282
|
+
Schema hash mismatches: ${summary.files.schemaHashMismatches.length}
|
|
283
|
+
|
|
284
|
+
Config hash mismatches: ${summary.files.configHashMismatches.length}
|
|
285
|
+
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
function formatMarkdownList(items, empty) {
|
|
289
|
+
return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : `- ${empty}`;
|
|
290
|
+
}
|
|
291
|
+
function createReadinessSummary(input) {
|
|
292
|
+
const blockers = [
|
|
293
|
+
...input.failedChecks.map((check) => `Failed check: ${check}.`),
|
|
294
|
+
...input.drift.length > 0 ? ["Generated output drift is present."] : [],
|
|
295
|
+
...input.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => `Error diagnostic: ${diagnostic.code}.`)
|
|
296
|
+
];
|
|
297
|
+
const warningDiagnostics = input.diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
|
298
|
+
const warnings = [
|
|
299
|
+
...warningDiagnostics.length > 0 ? [`${warningDiagnostics.length} warning diagnostic${warningDiagnostics.length === 1 ? "" : "s"} need review.`] : [],
|
|
300
|
+
...input.summary.healthScore < 90 ? [`Health score is ${input.summary.healthScore}, below the recommended pilot threshold of 90.`] : []
|
|
301
|
+
];
|
|
302
|
+
const status = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "needs-attention" : "ready";
|
|
303
|
+
const gate = status === "ready" ? {
|
|
304
|
+
result: "pass",
|
|
305
|
+
recommendedCiMode: "block",
|
|
306
|
+
reason: "Keep the blocking check enabled; no report findings require human acceptance."
|
|
307
|
+
} : status === "needs-attention" ? {
|
|
308
|
+
result: "warn",
|
|
309
|
+
recommendedCiMode: "comment",
|
|
310
|
+
reason: "Use comment-only mode until warnings are triaged or accepted."
|
|
311
|
+
} : {
|
|
312
|
+
result: "fail",
|
|
313
|
+
recommendedCiMode: "block",
|
|
314
|
+
reason: "Block merge or require explicit acceptance before pilot handoff."
|
|
315
|
+
};
|
|
316
|
+
const nextActions = status === "ready" ? ["Use the report as the pilot readiness artifact and keep `archora-forge check` in CI."] : [
|
|
317
|
+
...input.drift.length > 0 ? ["Run `archora-forge generate` and commit the generated output, or review intentional drift before the pilot handoff."] : [],
|
|
318
|
+
...input.diagnostics.length > 0 ? ["Review diagnostics and decide which schema fixes are required for the pilot scope."] : [],
|
|
319
|
+
...input.summary.healthScore < 90 ? ["Improve schema health before using the report as a go/no-go artifact."] : []
|
|
320
|
+
];
|
|
321
|
+
return {
|
|
322
|
+
status,
|
|
323
|
+
gate,
|
|
324
|
+
decision: status === "ready" ? "Schema is ready for a pilot readiness handoff under the current check policy." : status === "needs-attention" ? "Schema can continue through pilot review, but findings should be triaged first." : "Schema is not ready for pilot handoff until blockers are resolved or explicitly accepted.",
|
|
325
|
+
blockers,
|
|
326
|
+
warnings,
|
|
327
|
+
nextActions: nextActions.length > 0 ? nextActions : ["Review failed checks and diagnostics before the pilot handoff."],
|
|
328
|
+
summary: input.summary
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function evaluateFailedChecks(input) {
|
|
332
|
+
const failed = /* @__PURE__ */ new Set();
|
|
333
|
+
if (input.ci.failOnDrift && input.drift.length > 0) failed.add("drift");
|
|
334
|
+
if (input.ci.minHealthScore !== void 0 && input.healthScore < input.ci.minHealthScore) failed.add("health-score");
|
|
335
|
+
if (input.diagnostics.some((diagnostic) => diagnostic.severity === "error")) failed.add("errors");
|
|
336
|
+
if (input.ci.failOnWarnings && input.diagnostics.some((diagnostic) => diagnostic.severity === "warning")) failed.add("warnings");
|
|
337
|
+
if (input.ci.failOnUnsupportedFeatures && input.diagnostics.some((diagnostic) => diagnostic.code.startsWith("unsupported-"))) {
|
|
338
|
+
failed.add("unsupported-features");
|
|
339
|
+
}
|
|
340
|
+
if (input.ci.failOnMissingSchemas && input.diagnostics.some((diagnostic) => diagnostic.code === "missing-request-schema" || diagnostic.code === "missing-response-schema")) {
|
|
341
|
+
failed.add("missing-schemas");
|
|
342
|
+
}
|
|
343
|
+
return [...failed];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/commands/ci.command.ts
|
|
347
|
+
import { readFile } from "fs/promises";
|
|
348
|
+
import { requireCommercialLicense as requireCommercialLicense3, writeReportFile as writeReportFile2, logger as logger3 } from "@archora/forge-cli/internal";
|
|
349
|
+
var PROVIDERS = {
|
|
350
|
+
github: {
|
|
351
|
+
workflowPath: ".github/workflows/archora-forge-impact.yml",
|
|
352
|
+
build: createGithubWorkflow
|
|
353
|
+
},
|
|
354
|
+
gitlab: {
|
|
355
|
+
workflowPath: ".gitlab/archora-forge-impact.yml",
|
|
356
|
+
build: createGitlabWorkflow
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
function registerCiCommand(cli) {
|
|
360
|
+
cli.command("ci <action> <provider>", "Scaffold a turnkey CI kit for Forge impact review").option("--force", "Overwrite an existing workflow file when its content differs").option("--output <path>", "Write the workflow to a custom path").option("--mode <mode>", "Workflow mode: impact or pilot").option("--gate <mode>", "Merge gate mode: block or comment").option("--schema <path>", "OpenAPI schema path inside the repository").option("--base <ref>", "Git base ref for the previous schema").option("--no-readme", "Skip writing the FORGE_CI.md handoff document").action(async (action, provider, options) => {
|
|
361
|
+
await requireCommercialLicense3("ci");
|
|
362
|
+
if (action !== "init") throw new Error(`Unknown CI action "${action}". Use init.`);
|
|
363
|
+
if (!isProvider(provider)) {
|
|
364
|
+
throw new Error(`Unsupported CI provider "${provider}". Use github or gitlab.`);
|
|
365
|
+
}
|
|
366
|
+
const config = {
|
|
367
|
+
mode: normalizeMode(options.mode),
|
|
368
|
+
gate: normalizeGate(options.gate),
|
|
369
|
+
schema: options.schema ?? "openapi.yaml",
|
|
370
|
+
base: options.base ?? "origin/main"
|
|
371
|
+
};
|
|
372
|
+
const target = PROVIDERS[provider];
|
|
373
|
+
const workflowPath = options.output ?? target.workflowPath;
|
|
374
|
+
const workflowResult = await writeManaged(workflowPath, target.build(config), options.force);
|
|
375
|
+
reportWrite(workflowPath, workflowResult);
|
|
376
|
+
if (options.readme !== false) {
|
|
377
|
+
const readmeResult = await writeManaged(
|
|
378
|
+
"FORGE_CI.md",
|
|
379
|
+
createHandoff(provider, config, workflowPath),
|
|
380
|
+
options.force
|
|
381
|
+
);
|
|
382
|
+
reportWrite("FORGE_CI.md", readmeResult);
|
|
383
|
+
}
|
|
384
|
+
logger3.line(
|
|
385
|
+
provider === "gitlab" ? `Next: add \`include: { local: '${workflowPath}' }\` to your .gitlab-ci.yml.` : `Next: review OPENAPI_SCHEMA and OPENAPI_BASE_REF in ${workflowPath}.`
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async function writeManaged(path, content, force) {
|
|
390
|
+
const existing = await readFile(path, "utf8").catch(() => null);
|
|
391
|
+
if (existing === null) {
|
|
392
|
+
await writeReportFile2(path, content);
|
|
393
|
+
return "created";
|
|
394
|
+
}
|
|
395
|
+
if (existing === content) return "unchanged";
|
|
396
|
+
if (!force) return "skipped";
|
|
397
|
+
await writeReportFile2(path, content);
|
|
398
|
+
return "updated";
|
|
399
|
+
}
|
|
400
|
+
function reportWrite(path, result) {
|
|
401
|
+
if (result === "created") logger3.success(`Created ${path}`);
|
|
402
|
+
else if (result === "updated") logger3.success(`Updated ${path}`);
|
|
403
|
+
else if (result === "unchanged") logger3.line(`${path} is already up to date`);
|
|
404
|
+
else {
|
|
405
|
+
logger3.warn(`${path} already exists with different content`);
|
|
406
|
+
logger3.line("Re-run with --force to overwrite it.");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function isProvider(value) {
|
|
410
|
+
return value === "github" || value === "gitlab";
|
|
411
|
+
}
|
|
412
|
+
function normalizeMode(value) {
|
|
413
|
+
if (!value) return "impact";
|
|
414
|
+
if (value === "impact" || value === "pilot") return value;
|
|
415
|
+
throw new Error("Invalid CI mode. Use impact or pilot.");
|
|
416
|
+
}
|
|
417
|
+
function normalizeGate(value) {
|
|
418
|
+
if (!value) return "block";
|
|
419
|
+
if (value === "block" || value === "comment") return value;
|
|
420
|
+
throw new Error("Invalid CI gate mode. Use block or comment.");
|
|
421
|
+
}
|
|
422
|
+
function createGithubWorkflow(config) {
|
|
423
|
+
const runStep = config.mode === "pilot" ? ` pnpm exec archora-forge pilot "$OPENAPI_SCHEMA" --base "$OPENAPI_BASE_REF" \\
|
|
424
|
+
--repo . \\
|
|
425
|
+
--out forge-pilot \\
|
|
426
|
+
--skip-typecheck` : ` pnpm exec archora-forge impact "$OPENAPI_SCHEMA" --base "$OPENAPI_BASE_REF" \\
|
|
427
|
+
--repo . \\
|
|
428
|
+
--report markdown \\
|
|
429
|
+
--report-file forge-impact.md \\
|
|
430
|
+
--pr-comment-file forge-impact-pr.md`;
|
|
431
|
+
const artifactPath = config.mode === "pilot" ? ` forge-pilot/**` : ` forge-impact.md
|
|
432
|
+
forge-impact-pr.md`;
|
|
433
|
+
const blockStep = config.gate === "block" ? `
|
|
434
|
+
- name: Block merge on blocked API impact
|
|
435
|
+
if: steps.forge.outcome == 'failure'
|
|
436
|
+
run: |
|
|
437
|
+
echo "Archora Forge reported blocked API impact. Review the PR comment and uploaded artifacts."
|
|
438
|
+
exit 1` : "";
|
|
439
|
+
return `name: archora-forge-impact
|
|
440
|
+
|
|
441
|
+
on:
|
|
442
|
+
pull_request:
|
|
443
|
+
paths:
|
|
444
|
+
- '**/*.yaml'
|
|
445
|
+
- '**/*.yml'
|
|
446
|
+
- '**/*.json'
|
|
447
|
+
|
|
448
|
+
permissions:
|
|
449
|
+
contents: read
|
|
450
|
+
pull-requests: write
|
|
451
|
+
|
|
452
|
+
env:
|
|
453
|
+
OPENAPI_SCHEMA: ${config.schema}
|
|
454
|
+
OPENAPI_BASE_REF: ${config.base}
|
|
455
|
+
FORGE_GATE_MODE: ${config.gate}
|
|
456
|
+
|
|
457
|
+
jobs:
|
|
458
|
+
${config.mode}:
|
|
459
|
+
runs-on: ubuntu-latest
|
|
460
|
+
steps:
|
|
461
|
+
- uses: actions/checkout@v4
|
|
462
|
+
with:
|
|
463
|
+
fetch-depth: 0
|
|
464
|
+
|
|
465
|
+
- uses: pnpm/action-setup@v4
|
|
466
|
+
with:
|
|
467
|
+
version: 9.15.4
|
|
468
|
+
|
|
469
|
+
- uses: actions/setup-node@v4
|
|
470
|
+
with:
|
|
471
|
+
node-version: 22
|
|
472
|
+
cache: pnpm
|
|
473
|
+
|
|
474
|
+
- run: pnpm install --frozen-lockfile
|
|
475
|
+
|
|
476
|
+
- name: Run Forge impact
|
|
477
|
+
id: forge
|
|
478
|
+
continue-on-error: true
|
|
479
|
+
run: |
|
|
480
|
+
${runStep}
|
|
481
|
+
|
|
482
|
+
- uses: actions/upload-artifact@v4
|
|
483
|
+
if: always()
|
|
484
|
+
with:
|
|
485
|
+
name: archora-forge-impact
|
|
486
|
+
path: |
|
|
487
|
+
${artifactPath}
|
|
488
|
+
|
|
489
|
+
- uses: thollander/actions-comment-pull-request@v3
|
|
490
|
+
if: always() && env.OPENAPI_SCHEMA != '' && hashFiles('forge-impact-pr.md') != ''
|
|
491
|
+
with:
|
|
492
|
+
file-path: forge-impact-pr.md
|
|
493
|
+
comment-tag: archora-forge-impact
|
|
494
|
+
${blockStep}
|
|
495
|
+
`;
|
|
496
|
+
}
|
|
497
|
+
function createGitlabWorkflow(config) {
|
|
498
|
+
const runCommand = config.mode === "pilot" ? `pnpm exec archora-forge pilot "$OPENAPI_SCHEMA" --base "$OPENAPI_BASE_REF" --repo . --out forge-pilot --skip-typecheck` : `pnpm exec archora-forge impact "$OPENAPI_SCHEMA" --base "$OPENAPI_BASE_REF" --repo . --report markdown --report-file forge-impact.md --pr-comment-file forge-impact-pr.md`;
|
|
499
|
+
const script = config.gate === "comment" ? `${runCommand} || true` : runCommand;
|
|
500
|
+
const artifacts = config.mode === "pilot" ? ` - forge-pilot/` : ` - forge-impact.md
|
|
501
|
+
- forge-impact-pr.md`;
|
|
502
|
+
return `# Include this file from your .gitlab-ci.yml:
|
|
503
|
+
# include:
|
|
504
|
+
# - local: '${PROVIDERS.gitlab.workflowPath}'
|
|
505
|
+
|
|
506
|
+
archora-forge-impact:
|
|
507
|
+
stage: test
|
|
508
|
+
image: node:22
|
|
509
|
+
variables:
|
|
510
|
+
OPENAPI_SCHEMA: ${config.schema}
|
|
511
|
+
OPENAPI_BASE_REF: ${config.base}
|
|
512
|
+
FORGE_GATE_MODE: ${config.gate}
|
|
513
|
+
rules:
|
|
514
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
515
|
+
before_script:
|
|
516
|
+
- corepack enable
|
|
517
|
+
- corepack prepare pnpm@9.15.4 --activate
|
|
518
|
+
- git fetch origin "$OPENAPI_BASE_REF" || true
|
|
519
|
+
- pnpm install --frozen-lockfile
|
|
520
|
+
script:
|
|
521
|
+
- ${script}
|
|
522
|
+
artifacts:
|
|
523
|
+
when: always
|
|
524
|
+
expire_in: 1 week
|
|
525
|
+
paths:
|
|
526
|
+
${artifacts}
|
|
527
|
+
`;
|
|
528
|
+
}
|
|
529
|
+
function createHandoff(provider, config, workflowPath) {
|
|
530
|
+
const gateLine = config.gate === "block" ? "The pipeline **blocks the merge** when Forge reports a blocked frontend API change." : "The pipeline stays green and posts the impact report as a **comment/artifact** for reviewers.";
|
|
531
|
+
const unblock = config.gate === "block" ? `## Temporarily unblock a merge
|
|
532
|
+
|
|
533
|
+
Set the gate to advisory and re-run \`archora-forge ci init ${provider} --gate comment --force\`,
|
|
534
|
+
or resolve the blocking change and push again.` : "";
|
|
535
|
+
return `# Forge CI Kit
|
|
536
|
+
|
|
537
|
+
This repository runs Archora Forge in CI to review OpenAPI changes before they break frontend code.
|
|
538
|
+
|
|
539
|
+
- Provider: ${provider}
|
|
540
|
+
- Workflow: \`${workflowPath}\`
|
|
541
|
+
- Mode: ${config.mode}
|
|
542
|
+
- Gate: ${config.gate}
|
|
543
|
+
- Schema: \`${config.schema}\`
|
|
544
|
+
- Base ref: \`${config.base}\`
|
|
545
|
+
|
|
546
|
+
## What it does
|
|
547
|
+
|
|
548
|
+
On every ${provider === "gitlab" ? "merge request" : "pull request"}, Forge compares the new OpenAPI
|
|
549
|
+
schema against \`${config.base}\` and reports the frontend impact: breaking changes, affected
|
|
550
|
+
generated files and a migration summary. ${gateLine}
|
|
551
|
+
|
|
552
|
+
## How to read it
|
|
553
|
+
|
|
554
|
+
- Open the \`archora-forge-impact\` artifact (\`forge-impact.md\`) for the full report.
|
|
555
|
+
- ${provider === "gitlab" ? "Review the impact artifact attached to the pipeline." : "Read the `forge-impact-pr.md` comment posted on the PR."}
|
|
556
|
+
- A blocked decision means a breaking frontend contract change needs handling before merge.
|
|
557
|
+
|
|
558
|
+
${unblock}
|
|
559
|
+
To change schema path, base ref, mode or gate, re-run \`archora-forge ci init ${provider}\` with
|
|
560
|
+
the matching flags (add \`--force\` to overwrite).
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/commands/contract-diff.command.ts
|
|
565
|
+
import { diffOpenApiContracts, normalizeOpenApi as normalizeOpenApi2, parseOpenApi as parseOpenApi2 } from "@archora/forge-core";
|
|
566
|
+
import { createHtmlReport as createHtmlReport2, readGitBaseSchema, formatImpactReport, formatPullRequestComment, scanSourceUsages, requireCommercialLicense as requireCommercialLicense4, writeReportFile as writeReportFile3, parseSchemaRequestHeaders, logger as logger4 } from "@archora/forge-cli/internal";
|
|
567
|
+
function registerContractDiffCommand(cli) {
|
|
568
|
+
cli.command("contract-diff <oldSchema> <newSchema>", "Compare two OpenAPI schemas and report frontend impact").option("--schema-header <header>", 'Add a remote schema request header as "name:value" or "name=value"').option("--json", "Print machine-readable JSON").option("--report <format>", "Print a report format: json or html").option("--report-file <path>", "Write the selected contract diff report to a file").action(async (oldSchema, newSchema, options) => {
|
|
569
|
+
assertReportFormat2(options.report);
|
|
570
|
+
const schemaRequest = { headers: parseSchemaRequestHeaders(options.schemaHeader) };
|
|
571
|
+
const oldDocument = await parseOpenApi2(oldSchema, schemaRequest);
|
|
572
|
+
const newDocument = await parseOpenApi2(newSchema, schemaRequest);
|
|
573
|
+
const report = diffOpenApiContracts(normalizeOpenApi2(oldDocument), normalizeOpenApi2(newDocument));
|
|
574
|
+
const ok = !report.changes.some((change) => change.severity === "breaking");
|
|
575
|
+
const payload = { ok, oldSchema, newSchema, ...report };
|
|
576
|
+
if (options.reportFile) {
|
|
577
|
+
const reportPath = await writeReportFile3(options.reportFile, options.report === "html" ? createHtmlReport2("Archora Forge Contract Diff", payload) : JSON.stringify(payload, null, 2));
|
|
578
|
+
console.log(`Report written: ${reportPath}`);
|
|
579
|
+
} else if (options.json || options.report === "json") {
|
|
580
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
581
|
+
} else if (options.report === "html") {
|
|
582
|
+
console.log(createHtmlReport2("Archora Forge Contract Diff", payload));
|
|
583
|
+
} else {
|
|
584
|
+
logger4.title();
|
|
585
|
+
logger4.line(`Old schema: ${oldSchema}`);
|
|
586
|
+
logger4.line(`New schema: ${newSchema}`);
|
|
587
|
+
logger4.line(`Changes: ${report.changes.length}`);
|
|
588
|
+
logger4.line(`Affected resources: ${report.affectedResources.length > 0 ? report.affectedResources.join(", ") : "none"}`);
|
|
589
|
+
logger4.line("");
|
|
590
|
+
for (const change of report.changes.slice(0, 40)) {
|
|
591
|
+
const line = `${change.severity} ${change.code}: ${change.message} (${change.location})`;
|
|
592
|
+
if (change.severity === "breaking") logger4.warn(line);
|
|
593
|
+
else logger4.line(line);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
process.exitCode = ok ? 0 : 1;
|
|
597
|
+
});
|
|
598
|
+
cli.command("impact <schema> [newSchema]", "Create a frontend API impact report for a contract change").option("--schema-header <header>", 'Add a remote schema request header as "name:value" or "name=value"').option("--base <ref>", "Read the previous schema from this git ref").option("--json", "Print machine-readable JSON").option("--report <format>", "Print a report format: json, markdown or html").option("--report-file <path>", "Write the selected impact report to a file").option("--repo <path>", "Scan a frontend repository for impacted generated API usages").option("--pr-comment-file <path>", "Write a pull-request comment Markdown artifact").action(async (schema, newSchema, options) => {
|
|
599
|
+
await requireCommercialLicense4("impact");
|
|
600
|
+
assertImpactReportFormat(options.report);
|
|
601
|
+
const schemaRequest = { headers: parseSchemaRequestHeaders(options.schemaHeader) };
|
|
602
|
+
if (options.base && newSchema) throw new Error("Use either impact <oldSchema> <newSchema> or impact <schema> --base <ref>, not both.");
|
|
603
|
+
if (!options.base && !newSchema) throw new Error("Missing <newSchema>. Use impact <oldSchema> <newSchema> or impact <schema> --base <ref>.");
|
|
604
|
+
const baseSchema = options.base ? await readGitBaseSchema(schema, { base: options.base, repo: options.repo }) : null;
|
|
605
|
+
try {
|
|
606
|
+
const oldSchema = baseSchema?.path ?? schema;
|
|
607
|
+
const currentSchema = baseSchema ? schema : newSchema;
|
|
608
|
+
const oldDocument = await parseOpenApi2(oldSchema, schemaRequest);
|
|
609
|
+
const newDocument = await parseOpenApi2(currentSchema, schemaRequest);
|
|
610
|
+
const report = diffOpenApiContracts(normalizeOpenApi2(oldDocument), normalizeOpenApi2(newDocument));
|
|
611
|
+
const ok = report.decision.status !== "blocked";
|
|
612
|
+
const sourceUsages = options.repo ? await scanSourceUsages(options.repo, report) : [];
|
|
613
|
+
const payload = {
|
|
614
|
+
ok,
|
|
615
|
+
...baseSchema ? { base: baseSchema.base } : {},
|
|
616
|
+
oldSchema: baseSchema?.label ?? oldSchema,
|
|
617
|
+
newSchema: currentSchema,
|
|
618
|
+
sourceUsages,
|
|
619
|
+
...report
|
|
620
|
+
};
|
|
621
|
+
const reportFormat = options.json ? "json" : options.report ?? "markdown";
|
|
622
|
+
if (options.reportFile) {
|
|
623
|
+
const reportPath = await writeReportFile3(options.reportFile, formatImpactReport(reportFormat, payload));
|
|
624
|
+
console.log(`Report written: ${reportPath}`);
|
|
625
|
+
} else if (reportFormat === "json") {
|
|
626
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
627
|
+
} else if (reportFormat === "html") {
|
|
628
|
+
console.log(createHtmlReport2("Frontend Impact Center", payload));
|
|
629
|
+
} else {
|
|
630
|
+
console.log(formatImpactReport("markdown", payload));
|
|
631
|
+
}
|
|
632
|
+
if (options.prCommentFile) {
|
|
633
|
+
const commentPath = await writeReportFile3(options.prCommentFile, formatPullRequestComment(payload));
|
|
634
|
+
console.log(`PR comment written: ${commentPath}`);
|
|
635
|
+
}
|
|
636
|
+
process.exitCode = ok ? 0 : 1;
|
|
637
|
+
} finally {
|
|
638
|
+
await baseSchema?.cleanup();
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function assertReportFormat2(report) {
|
|
643
|
+
if (report !== void 0 && report !== "json" && report !== "html") {
|
|
644
|
+
throw new Error(`Invalid report format "${report}". Expected "json" or "html".`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function assertImpactReportFormat(report) {
|
|
648
|
+
if (report !== void 0 && report !== "json" && report !== "markdown" && report !== "html") {
|
|
649
|
+
throw new Error(`Invalid report format "${report}". Expected "json", "markdown" or "html".`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/commands/pilot.command.ts
|
|
654
|
+
import { mkdir } from "fs/promises";
|
|
655
|
+
import { join, resolve } from "path";
|
|
656
|
+
import { diffOpenApiContracts as diffOpenApiContracts2, normalizeOpenApi as normalizeOpenApi3, parseOpenApi as parseOpenApi3 } from "@archora/forge-core";
|
|
657
|
+
import { readGitBaseSchema as readGitBaseSchema2, formatImpactReport as formatImpactReport2, formatPullRequestComment as formatPullRequestComment2, scanSourceUsages as scanSourceUsages2, requireCommercialLicense as requireCommercialLicense5, writeReportFile as writeReportFile4, parseSchemaRequestHeaders as parseSchemaRequestHeaders2, logger as logger5, runAuditPackage as runAuditPackage2 } from "@archora/forge-cli/internal";
|
|
658
|
+
function registerPilotCommand(cli) {
|
|
659
|
+
cli.command("pilot <schema>", "Create a paid-pilot package with impact, audit and go/no-go artifacts").option("--old <schema>", "Previous OpenAPI schema for impact review").option("--base <ref>", "Read the previous schema from this git ref").option("--repo <path>", "Scan a frontend repository for impacted generated API usages").option("--out <path>", "Output directory for the pilot package").option("--schema-header <header>", 'Add a remote schema request header as "name:value" or "name=value"').option("--skip-typecheck", "Skip generated-output TypeScript typecheck").option("--min-health-score <score>", "Use this health score as the audit acceptance threshold").action(async (schema, options) => {
|
|
660
|
+
try {
|
|
661
|
+
await requireCommercialLicense5("pilot");
|
|
662
|
+
if (!options.old && !options.base) throw new Error("Missing --old <schema> or --base <ref>. Pilot packages need a previous schema for impact review.");
|
|
663
|
+
if (options.old && options.base) throw new Error("Use either --old <schema> or --base <ref>, not both.");
|
|
664
|
+
const outDir = resolve(options.out ?? "forge-pilot");
|
|
665
|
+
await mkdir(outDir, { recursive: true });
|
|
666
|
+
const impact = await createPilotImpact(schema, options);
|
|
667
|
+
await Promise.all([
|
|
668
|
+
writeReportFile4(join(outDir, "impact.md"), formatImpactReport2("markdown", impact)),
|
|
669
|
+
writeReportFile4(join(outDir, "impact.json"), JSON.stringify(impact, null, 2)),
|
|
670
|
+
writeReportFile4(join(outDir, "impact-pr.md"), formatPullRequestComment2(impact))
|
|
671
|
+
]);
|
|
672
|
+
const audit = await runAuditPackage2(schema, {
|
|
673
|
+
config: void 0,
|
|
674
|
+
out: join(outDir, "audit"),
|
|
675
|
+
json: false,
|
|
676
|
+
skipTypecheck: options.skipTypecheck,
|
|
677
|
+
minHealthScore: options.minHealthScore,
|
|
678
|
+
schemaHeader: options.schemaHeader
|
|
679
|
+
});
|
|
680
|
+
const decision = createGoNoGo({ impact, auditOk: audit.payload.ok, auditDecision: audit.payload.readiness.decision });
|
|
681
|
+
await Promise.all([
|
|
682
|
+
writeReportFile4(join(outDir, "go-no-go.md"), decision.markdown),
|
|
683
|
+
writeReportFile4(join(outDir, "pilot-report.md"), createPilotReport({ impact, status: decision.status, auditOk: audit.payload.ok, auditDecision: audit.payload.readiness.decision }))
|
|
684
|
+
]);
|
|
685
|
+
logger5.title();
|
|
686
|
+
logger5.line(`Pilot package: ${outDir}`);
|
|
687
|
+
logger5.line(`Decision: ${decision.status}`);
|
|
688
|
+
logger5.line(`Impact: ${impact.decision.status}`);
|
|
689
|
+
logger5.line(`Audit: ${audit.payload.ok ? "passed" : "failed"}`);
|
|
690
|
+
process.exitCode = decision.status === "go" ? 0 : decision.status === "conditional-go" ? 1 : 1;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
logger5.error(error instanceof Error ? error.message : String(error));
|
|
693
|
+
process.exitCode = 2;
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
async function createPilotImpact(newSchema, options) {
|
|
698
|
+
const schemaRequest = { headers: parseSchemaRequestHeaders2(options.schemaHeader) };
|
|
699
|
+
const baseSchema = options.base ? await readGitBaseSchema2(newSchema, { base: options.base, repo: options.repo }) : null;
|
|
700
|
+
try {
|
|
701
|
+
const oldSchema = baseSchema?.path ?? options.old;
|
|
702
|
+
const oldDocument = await parseOpenApi3(oldSchema, schemaRequest);
|
|
703
|
+
const newDocument = await parseOpenApi3(newSchema, schemaRequest);
|
|
704
|
+
const report = diffOpenApiContracts2(normalizeOpenApi3(oldDocument), normalizeOpenApi3(newDocument));
|
|
705
|
+
const sourceUsages = options.repo ? await scanSourceUsages2(options.repo, report) : [];
|
|
706
|
+
return {
|
|
707
|
+
ok: report.decision.status !== "blocked",
|
|
708
|
+
...baseSchema ? { base: baseSchema.base } : {},
|
|
709
|
+
oldSchema: baseSchema?.label ?? oldSchema,
|
|
710
|
+
newSchema,
|
|
711
|
+
sourceUsages,
|
|
712
|
+
...report
|
|
713
|
+
};
|
|
714
|
+
} finally {
|
|
715
|
+
await baseSchema?.cleanup();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function createGoNoGo(input) {
|
|
719
|
+
const blockers = [
|
|
720
|
+
...input.impact.decision.status === "blocked" ? ["Impact decision is blocked."] : [],
|
|
721
|
+
...!input.auditOk ? ["Audit readiness failed."] : []
|
|
722
|
+
];
|
|
723
|
+
const warnings = [
|
|
724
|
+
...input.impact.decision.status === "review" ? ["Impact decision needs review."] : [],
|
|
725
|
+
...input.impact.sourceUsages && input.impact.sourceUsages.length === 0 ? ["No impacted source usages were found."] : []
|
|
726
|
+
];
|
|
727
|
+
const status = blockers.length > 0 ? "no-go" : warnings.length > 0 ? "conditional-go" : "go";
|
|
728
|
+
const lines = [
|
|
729
|
+
"# Forge Pilot Go/No-Go",
|
|
730
|
+
"",
|
|
731
|
+
`Decision: ${status}`,
|
|
732
|
+
"",
|
|
733
|
+
"## Evidence",
|
|
734
|
+
"",
|
|
735
|
+
...input.impact.base ? [`- Base ref: ${input.impact.base}`] : [],
|
|
736
|
+
`- Old schema: ${input.impact.oldSchema}`,
|
|
737
|
+
`- New schema: ${input.impact.newSchema}`,
|
|
738
|
+
`- Impact decision: ${input.impact.decision.status}`,
|
|
739
|
+
`- Impact merge risk: ${input.impact.decision.mergeRisk}`,
|
|
740
|
+
`- Breaking changes: ${input.impact.summary.breaking}`,
|
|
741
|
+
`- Warnings: ${input.impact.summary.warnings}`,
|
|
742
|
+
`- Source usage matches: ${input.impact.sourceUsages?.length ?? 0}`,
|
|
743
|
+
`- Audit decision: ${input.auditDecision}`,
|
|
744
|
+
"",
|
|
745
|
+
"## Blockers",
|
|
746
|
+
"",
|
|
747
|
+
...blockers.length > 0 ? blockers.map((item) => `- ${item}`) : ["No blockers."],
|
|
748
|
+
"",
|
|
749
|
+
"## Warnings",
|
|
750
|
+
"",
|
|
751
|
+
...warnings.length > 0 ? warnings.map((item) => `- ${item}`) : ["No warnings."],
|
|
752
|
+
"",
|
|
753
|
+
"## Next Actions",
|
|
754
|
+
"",
|
|
755
|
+
...status === "go" ? ["- Add Forge impact and audit checks to CI.", "- Generate the resource layer in a branch and review it with the frontend team."] : status === "conditional-go" ? ["- Review warnings with the frontend owner.", "- Decide whether accepted warnings should be documented in the pilot report."] : ["- Fix blockers before buying or rolling out Forge.", "- Re-run `archora-forge pilot` after schema or config changes."],
|
|
756
|
+
""
|
|
757
|
+
];
|
|
758
|
+
return { status, markdown: lines.join("\n") };
|
|
759
|
+
}
|
|
760
|
+
function createPilotReport(input) {
|
|
761
|
+
return [
|
|
762
|
+
"# Archora Forge Pilot Report",
|
|
763
|
+
"",
|
|
764
|
+
"## Decision",
|
|
765
|
+
"",
|
|
766
|
+
`Decision: ${input.status}`,
|
|
767
|
+
`Impact: ${input.impact.decision.status}`,
|
|
768
|
+
`Merge risk: ${input.impact.decision.mergeRisk}`,
|
|
769
|
+
`Audit: ${input.auditOk ? "passed" : "failed"}`,
|
|
770
|
+
"",
|
|
771
|
+
"## Artifact Links",
|
|
772
|
+
"",
|
|
773
|
+
"- `impact-pr.md` - PR comment for reviewers.",
|
|
774
|
+
"- `impact.md` - full frontend API impact report.",
|
|
775
|
+
"- `impact.json` - machine-readable impact payload.",
|
|
776
|
+
"- `audit/index.html` - generated output audit and readiness report.",
|
|
777
|
+
"- `audit/report.md` - Markdown audit handoff.",
|
|
778
|
+
"- `go-no-go.md` - short adoption decision.",
|
|
779
|
+
"",
|
|
780
|
+
"## Impact Summary",
|
|
781
|
+
"",
|
|
782
|
+
`- Old schema: ${input.impact.oldSchema}`,
|
|
783
|
+
`- New schema: ${input.impact.newSchema}`,
|
|
784
|
+
...input.impact.base ? [`- Base ref: ${input.impact.base}`] : [],
|
|
785
|
+
`- Breaking changes: ${input.impact.summary.breaking}`,
|
|
786
|
+
`- Warnings: ${input.impact.summary.warnings}`,
|
|
787
|
+
`- Non-breaking changes: ${input.impact.summary.nonBreaking}`,
|
|
788
|
+
`- Source usage matches: ${input.impact.sourceUsages?.length ?? 0}`,
|
|
789
|
+
"",
|
|
790
|
+
"## Affected Surface",
|
|
791
|
+
"",
|
|
792
|
+
`- Resources: ${formatInlineList(input.impact.affectedResources)}`,
|
|
793
|
+
`- Generated files: ${formatInlineList(input.impact.affectedFiles)}`,
|
|
794
|
+
`- Operation IDs: ${formatInlineList(input.impact.impactedSurface.operationIds)}`,
|
|
795
|
+
`- Client methods: ${formatInlineList(input.impact.impactedSurface.clientMethods)}`,
|
|
796
|
+
`- Query hooks: ${formatInlineList(input.impact.impactedSurface.queryHooks)}`,
|
|
797
|
+
"",
|
|
798
|
+
"## Reviewer Checklist",
|
|
799
|
+
"",
|
|
800
|
+
"- Confirm `impact-pr.md` gives a clear merge decision.",
|
|
801
|
+
"- Confirm `impact.md` names the breaking changes and affected generated files.",
|
|
802
|
+
"- Confirm `audit/index.html` matches the frontend resource model.",
|
|
803
|
+
"- Confirm `audit/typecheck.md` passed or every failure is triaged.",
|
|
804
|
+
"- Confirm `go-no-go.md` matches the team decision before purchase or rollout.",
|
|
805
|
+
"",
|
|
806
|
+
"## Next Review Step",
|
|
807
|
+
"",
|
|
808
|
+
input.status === "go" ? "Add the generated GitHub workflow, run the first PR with Forge comments enabled, then decide whether to commit generated output." : input.status === "conditional-go" ? "Review warnings with the frontend owner before committing generated output or widening scope." : "Do not roll out Forge on this schema until blockers are fixed or explicitly accepted by the frontend owner.",
|
|
809
|
+
""
|
|
810
|
+
].join("\n");
|
|
811
|
+
}
|
|
812
|
+
function formatInlineList(values) {
|
|
813
|
+
return values && values.length > 0 ? values.map((value) => `\`${value}\``).join(", ") : "none";
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/pro.ts
|
|
817
|
+
function registerProCommands(cli) {
|
|
818
|
+
registerPilotCommand(cli);
|
|
819
|
+
registerCiCommand(cli);
|
|
820
|
+
registerAuditCommand(cli);
|
|
821
|
+
registerCheckCommand(cli);
|
|
822
|
+
registerContractDiffCommand(cli);
|
|
823
|
+
}
|
|
824
|
+
export {
|
|
825
|
+
registerProCommands
|
|
826
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@archora/forge-pro",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Forge Intelligence: the commercial team/CI layer (impact, check, audit, ci, pilot) for Archora Forge.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/archora-dev/archora-forge.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/archora-dev/archora-forge/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/archora-dev/archora-forge#readme",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"cac": "^6.7.14",
|
|
31
|
+
"@archora/forge-cli": "2.0.0",
|
|
32
|
+
"@archora/forge-core": "2.0.0",
|
|
33
|
+
"@archora/forge-adapters": "2.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.15.17",
|
|
37
|
+
"tsup": "^8.4.0",
|
|
38
|
+
"typescript": "^5.8.3"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"dev": "tsup --watch",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|