@aion0/forge 0.5.48 → 0.5.49
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/CLAUDE.md +0 -1
- package/RELEASE_NOTES.md +8 -3
- package/app/api/tasks/[id]/log/entry/route.ts +13 -0
- package/app/api/tasks/[id]/log/route.ts +23 -0
- package/app/api/tasks/route.ts +2 -2
- package/components/ProjectDetail.tsx +1 -16
- package/components/TaskDetail.tsx +201 -51
- package/lib/help-docs/CLAUDE.md +0 -2
- package/lib/task-manager.ts +110 -0
- package/package.json +1 -1
- package/src/types/index.ts +7 -0
- package/app/api/migration/config/route.ts +0 -19
- package/app/api/migration/discover/route.ts +0 -26
- package/app/api/migration/failures/route.ts +0 -35
- package/app/api/migration/fix/route.ts +0 -82
- package/app/api/migration/run/route.ts +0 -22
- package/app/api/migration/run-batch/route.ts +0 -86
- package/components/MigrationCockpit.tsx +0 -541
- package/lib/help-docs/14-migration.md +0 -154
- package/lib/migration/differ.ts +0 -193
- package/lib/migration/discoverer.ts +0 -363
- package/lib/migration/openapi.ts +0 -137
- package/lib/migration/runner.ts +0 -219
- package/lib/migration/store.ts +0 -89
- package/lib/migration/types.ts +0 -115
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
# API Migration Cockpit
|
|
2
|
-
|
|
3
|
-
Parity-test API endpoints between a legacy module and a new module during a large migration. Designed for projects where the same paths must be served identically by two different processes (e.g. legacy on `:8080`, new web-server on `:9090`).
|
|
4
|
-
|
|
5
|
-
## When to use
|
|
6
|
-
|
|
7
|
-
- You're moving REST endpoints from one Spring/Java module to another and the new module **must keep the exact same paths** (no rewrites allowed in the legacy code).
|
|
8
|
-
- You have lots of endpoints (hundreds) and need batch testing + AI-assisted fix.
|
|
9
|
-
- You already track migration status in markdown — Forge reads your docs instead of re-scanning the source.
|
|
10
|
-
|
|
11
|
-
## Quick start
|
|
12
|
-
|
|
13
|
-
1. Open the project, click the **🚚 Migration** tab.
|
|
14
|
-
2. Click **Config** and set:
|
|
15
|
-
- `Legacy base URL` (e.g. `http://localhost:8080`)
|
|
16
|
-
- `New base URL` (e.g. `http://localhost:9090`)
|
|
17
|
-
- `Per-controller docs dir` (default `docs/migration`)
|
|
18
|
-
- `History fallback` (default `docs/lead/migration-history.md`)
|
|
19
|
-
3. Click **Discover from docs** — Forge parses your existing migration docs.
|
|
20
|
-
4. Click **Run all** to fire each endpoint at both base URLs in parallel and compare JSON.
|
|
21
|
-
5. Failures show up in the right sidebar, clustered by error type.
|
|
22
|
-
6. Select rows or a cluster → **AI fix → task** (background) or **AI fix → inject** (paste prompt into a tmux session).
|
|
23
|
-
|
|
24
|
-
## Discovery
|
|
25
|
-
|
|
26
|
-
Two strategies, controlled by `endpointSource.openApiSpec` in config:
|
|
27
|
-
|
|
28
|
-
### Strategy A — OpenAPI as primary source (recommended)
|
|
29
|
-
|
|
30
|
-
When `openApiSpec` points at an OpenAPI 3 JSON (e.g. `docs/fnac-rest-schema-7.6.json`), Forge:
|
|
31
|
-
|
|
32
|
-
1. Loads every operation in the spec — this is the full surface, including endpoints not yet covered by your migration docs.
|
|
33
|
-
2. Annotates each endpoint with status (`migrated` / `stubbed` / `pending`) by cross-referencing your `docs/migration/<X>.java.md` per-controller files (matched by exact `METHOD path`) and `docs/lead/migration-history.md` (matched by controller/tag name).
|
|
34
|
-
3. Resolves `$ref` chains so each operation has an inline response schema you can validate against.
|
|
35
|
-
|
|
36
|
-
Endpoints not covered by any doc are flagged `pending` so you can see what's not yet planned.
|
|
37
|
-
|
|
38
|
-
### Strategy B — Doc-only discovery (fallback)
|
|
39
|
-
|
|
40
|
-
If no `openApiSpec` is configured, Forge falls back to parsing `docs/migration/*.md` tables (Migrated / Stubbed sections) plus `migration-history.md` inline `METHOD /path` mentions.
|
|
41
|
-
|
|
42
|
-
### Primary parser — `docs/migration/<File>.java.md`
|
|
43
|
-
|
|
44
|
-
Looks for sections marked **Migrated** / **Stubbed** / **URL Parity Only** with markdown tables containing `` `METHOD` `path` `` cells:
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
### ✅ Migrated (11 endpoints — DB-backed)
|
|
48
|
-
|
|
49
|
-
| HTTP path | Method | Service method | Notes |
|
|
50
|
-
|---|---|---|---|
|
|
51
|
-
| `GET /control/{id}` | `getById` | `getById(id)` | Single task by PK |
|
|
52
|
-
|
|
53
|
-
### 🚫 Stubbed (12 endpoints — return 501 Not Implemented)
|
|
54
|
-
|
|
55
|
-
| HTTP path | Method | Runtime dependency |
|
|
56
|
-
|---|---|---|
|
|
57
|
-
| `POST /control/macaddress` | `controlByMacForm` | … |
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Stubbed endpoints get `expectedHttpStatus: 501` so they pass when the new side correctly returns 501.
|
|
61
|
-
|
|
62
|
-
### Fallback parser — `docs/lead/migration-history.md`
|
|
63
|
-
|
|
64
|
-
For controllers without a per-file doc, Forge scans entries shaped like:
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
- [x] `MFAController.java` — **migrated 2026-04-23** (… `GET /mfa/foo` …)
|
|
68
|
-
- [x] `Foo.java` — **skip 2026-04-23**: out of scope
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
`skip` and `defer` entries are excluded. Inline `` `METHOD /path` `` mentions in the description become endpoints; otherwise a placeholder row is added with a note.
|
|
72
|
-
|
|
73
|
-
## Running
|
|
74
|
-
|
|
75
|
-
| Action | Behavior |
|
|
76
|
-
|---|---|
|
|
77
|
-
| **Run** (per row) | Fires both sides once, expands diff inline |
|
|
78
|
-
| **Run selected** | Batch-run checked rows (SSE progress) |
|
|
79
|
-
| **Run all** | Batch-run every endpoint |
|
|
80
|
-
|
|
81
|
-
The runner fires `legacy` and `new` in parallel for each endpoint, with concurrency=4 across endpoints. Path placeholders like `{id}` are filled from `pathSubstitutions` in config.
|
|
82
|
-
|
|
83
|
-
### Diff modes (`config.diffMode`)
|
|
84
|
-
|
|
85
|
-
| Mode | Hits legacy? | Comparison |
|
|
86
|
-
|---|---|---|
|
|
87
|
-
| `shape` (default) | no | Validates new response against the OpenAPI schema (subset semantics — extra fields OK, missing required fail, wrong types fail, enum violations fail). Use this when legacy is unreachable. |
|
|
88
|
-
| `exact` | yes | Original behavior — fires both sides in parallel, deep-equal JSON comparison with array sort + ignore-paths. |
|
|
89
|
-
| `both` | yes | Both deep-equal AND schema validation. |
|
|
90
|
-
|
|
91
|
-
### Match outcomes
|
|
92
|
-
|
|
93
|
-
| Match | Meaning |
|
|
94
|
-
|---|---|
|
|
95
|
-
| `pass` | Comparison succeeded for the configured mode |
|
|
96
|
-
| `stub-ok` | Endpoint marked stubbed; new side returned 501 as expected |
|
|
97
|
-
| `fail` | Schema violation, status mismatch, or JSON diff |
|
|
98
|
-
| `error` | Unreachable or timed out |
|
|
99
|
-
|
|
100
|
-
Top-level arrays are sorted before exact comparison so order alone won't fail. Schema mode samples the first 10 array items to keep reports tractable.
|
|
101
|
-
|
|
102
|
-
## Config (`<project>/.forge/migration/config.yaml`)
|
|
103
|
-
|
|
104
|
-
```yaml
|
|
105
|
-
legacy:
|
|
106
|
-
baseUrl: http://localhost:8080
|
|
107
|
-
next:
|
|
108
|
-
baseUrl: http://localhost:9090
|
|
109
|
-
auth:
|
|
110
|
-
mode: skip # skip / bearer / basic
|
|
111
|
-
tokenEnv: FORTINAC_TOKEN
|
|
112
|
-
diffMode: shape # shape / exact / both
|
|
113
|
-
ignorePaths: # JSONPath patterns to skip during diff/validation
|
|
114
|
-
- $.timestamp
|
|
115
|
-
- $.requestId
|
|
116
|
-
healthCheck:
|
|
117
|
-
legacyTimeout: 2000
|
|
118
|
-
newTimeout: 2000
|
|
119
|
-
skipUnhealthy: true
|
|
120
|
-
clusterMode: simple # simple / ai
|
|
121
|
-
endpointSource:
|
|
122
|
-
type: mixed
|
|
123
|
-
openApiSpec: docs/fnac-rest-schema-7.6.json # primary source when set
|
|
124
|
-
primary: docs/migration # used to annotate status
|
|
125
|
-
fallback: docs/lead/migration-history.md # used to annotate status
|
|
126
|
-
pathSubstitutions:
|
|
127
|
-
id: "1"
|
|
128
|
-
ip: "127.0.0.1"
|
|
129
|
-
mac: "00:00:00:00:00:00"
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
## Storage
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
<project>/.forge/migration/
|
|
136
|
-
├── config.yaml
|
|
137
|
-
├── endpoints.json # discovered endpoints
|
|
138
|
-
├── runs/<timestamp>.json # one file per batch run
|
|
139
|
-
└── failures/current.json # latest failures (used by clustering)
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
## AI fix
|
|
143
|
-
|
|
144
|
-
After a batch run, failure clusters appear in the sidebar grouped by error type (`http-status-mismatch`, `json-diff`, `legacy-unreachable`, …) with sub-counts per controller.
|
|
145
|
-
|
|
146
|
-
- **AI fix → task** spawns a Forge background task in the project; the task receives a structured prompt naming the failing endpoints + diffs and is told *not* to modify legacy code.
|
|
147
|
-
- **AI fix → inject** prompts for a tmux session name and pastes the prompt + Enter into a running Claude Code session. Use this when you have a Claude terminal already open and want it to take over the fix.
|
|
148
|
-
|
|
149
|
-
## Tips
|
|
150
|
-
|
|
151
|
-
- The cockpit assumes both base URLs are reachable and serve identical paths. If the new module isn't running yet, every row will show `error` — that's expected.
|
|
152
|
-
- Stubbed endpoints intentionally return 501; they're still listed so you can audit URL parity.
|
|
153
|
-
- The diff output truncates each side to 4 KB and shows up to 50 jsonpath diffs per endpoint. Run output JSON files contain the full payload.
|
|
154
|
-
- Use the **Search** box to scope to one controller, then **Select all visible** + **Run selected** to focus on a single migration unit.
|
package/lib/migration/differ.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// Strict JSON deep-equal with a JSONPath-style ignore list.
|
|
2
|
-
// Supported ignore syntax: $.field, $.nested.field, $.array[*].field
|
|
3
|
-
|
|
4
|
-
import type { DiffEntry } from './types';
|
|
5
|
-
|
|
6
|
-
function compileIgnore(patterns: string[]): RegExp[] {
|
|
7
|
-
return patterns.map(p => {
|
|
8
|
-
const body = p.startsWith('$') ? p.slice(1) : p;
|
|
9
|
-
const escaped = body
|
|
10
|
-
.replace(/\./g, '\\.')
|
|
11
|
-
.replace(/\[\*\]/g, '\\[\\d+\\]')
|
|
12
|
-
.replace(/\[(\d+)\]/g, '\\[$1\\]');
|
|
13
|
-
return new RegExp(`^${escaped}$`);
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function isIgnored(path: string, compiled: RegExp[]): boolean {
|
|
18
|
-
return compiled.some(re => re.test(path));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeArray(arr: any[]): any[] {
|
|
22
|
-
// Sort by stable-stringify so order doesn't matter for top-level array results.
|
|
23
|
-
return [...arr].sort((a, b) => stableStringify(a).localeCompare(stableStringify(b)));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function stableStringify(v: any): string {
|
|
27
|
-
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
28
|
-
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
|
|
29
|
-
const keys = Object.keys(v).sort();
|
|
30
|
-
return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function diff(legacy: any, next: any, ignorePaths: string[] = [], opts: { sortArrays?: boolean } = {}): DiffEntry[] {
|
|
34
|
-
const compiled = compileIgnore(ignorePaths);
|
|
35
|
-
const out: DiffEntry[] = [];
|
|
36
|
-
walk(legacy, next, '$', compiled, out, !!opts.sortArrays);
|
|
37
|
-
return out;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function walk(a: any, b: any, path: string, compiled: RegExp[], out: DiffEntry[], sortArrays: boolean) {
|
|
41
|
-
if (isIgnored(path, compiled)) return;
|
|
42
|
-
|
|
43
|
-
const ta = a === null ? 'null' : Array.isArray(a) ? 'array' : typeof a;
|
|
44
|
-
const tb = b === null ? 'null' : Array.isArray(b) ? 'array' : typeof b;
|
|
45
|
-
|
|
46
|
-
if (ta !== tb) {
|
|
47
|
-
out.push({ jsonPath: path, legacy: a, next: b, reason: 'type-mismatch' });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (ta === 'array') {
|
|
52
|
-
let aa = a, bb = b;
|
|
53
|
-
if (sortArrays) { aa = normalizeArray(a); bb = normalizeArray(b); }
|
|
54
|
-
const len = Math.max(aa.length, bb.length);
|
|
55
|
-
for (let i = 0; i < len; i++) {
|
|
56
|
-
const cp = `${path}[${i}]`;
|
|
57
|
-
if (i >= aa.length) out.push({ jsonPath: cp, legacy: undefined, next: bb[i], reason: 'missing-in-legacy' });
|
|
58
|
-
else if (i >= bb.length) out.push({ jsonPath: cp, legacy: aa[i], next: undefined, reason: 'missing-in-next' });
|
|
59
|
-
else walk(aa[i], bb[i], cp, compiled, out, sortArrays);
|
|
60
|
-
}
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (ta === 'object') {
|
|
65
|
-
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
66
|
-
for (const k of keys) {
|
|
67
|
-
const cp = `${path}.${k}`;
|
|
68
|
-
if (!(k in a)) out.push({ jsonPath: cp, legacy: undefined, next: b[k], reason: 'missing-in-legacy' });
|
|
69
|
-
else if (!(k in b)) out.push({ jsonPath: cp, legacy: a[k], next: undefined, reason: 'missing-in-next' });
|
|
70
|
-
else walk(a[k], b[k], cp, compiled, out, sortArrays);
|
|
71
|
-
}
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (a !== b) out.push({ jsonPath: path, legacy: a, next: b, reason: 'value' });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ── OpenAPI schema validator (subset shape mode) ─────────
|
|
79
|
-
// Validates `value` against an inlined OpenAPI 3 schema.
|
|
80
|
-
// Subset semantics: extra fields in `value` are OK; missing required fields fail.
|
|
81
|
-
|
|
82
|
-
export interface SchemaViolation {
|
|
83
|
-
jsonPath: string;
|
|
84
|
-
expected: string;
|
|
85
|
-
actual: string;
|
|
86
|
-
reason: 'missing-required' | 'type-mismatch' | 'enum-mismatch' | 'unresolved';
|
|
87
|
-
detail?: any;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function validateAgainstSchema(value: any, schema: any, path = '$', out: SchemaViolation[] = [], ignore?: RegExp[]): SchemaViolation[] {
|
|
91
|
-
if (!schema || typeof schema !== 'object') return out;
|
|
92
|
-
if (schema.__cycle || schema.__unresolved) return out;
|
|
93
|
-
if (ignore && ignore.some(re => re.test(path))) return out;
|
|
94
|
-
|
|
95
|
-
// oneOf / anyOf — pass if any branch validates with no violations.
|
|
96
|
-
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
|
|
97
|
-
const branches = schema.oneOf || schema.anyOf;
|
|
98
|
-
let bestErrors: SchemaViolation[] | null = null;
|
|
99
|
-
for (const branch of branches) {
|
|
100
|
-
const errs: SchemaViolation[] = [];
|
|
101
|
-
validateAgainstSchema(value, branch, path, errs, ignore);
|
|
102
|
-
if (errs.length === 0) return out;
|
|
103
|
-
if (!bestErrors || errs.length < bestErrors.length) bestErrors = errs;
|
|
104
|
-
}
|
|
105
|
-
if (bestErrors) out.push(...bestErrors);
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
|
-
if (Array.isArray(schema.allOf)) {
|
|
109
|
-
for (const branch of schema.allOf) validateAgainstSchema(value, branch, path, out, ignore);
|
|
110
|
-
return out;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const t = schema.type;
|
|
114
|
-
const actualType = jsType(value);
|
|
115
|
-
|
|
116
|
-
// Nullable check
|
|
117
|
-
if (value === null) {
|
|
118
|
-
if (schema.nullable === true) return out;
|
|
119
|
-
if (Array.isArray(t) && t.includes('null')) return out;
|
|
120
|
-
if (t === 'null') return out;
|
|
121
|
-
out.push({ jsonPath: path, expected: String(t || 'non-null'), actual: 'null', reason: 'type-mismatch' });
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (t === 'object' || (t === undefined && schema.properties)) {
|
|
126
|
-
if (actualType !== 'object') {
|
|
127
|
-
out.push({ jsonPath: path, expected: 'object', actual: actualType, reason: 'type-mismatch' });
|
|
128
|
-
return out;
|
|
129
|
-
}
|
|
130
|
-
const required: string[] = schema.required || [];
|
|
131
|
-
for (const r of required) {
|
|
132
|
-
if (!(r in value)) {
|
|
133
|
-
out.push({ jsonPath: `${path}.${r}`, expected: 'required', actual: 'missing', reason: 'missing-required' });
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
const props = schema.properties || {};
|
|
137
|
-
for (const [k, sub] of Object.entries(props)) {
|
|
138
|
-
if (k in value) validateAgainstSchema(value[k], sub, `${path}.${k}`, out, ignore);
|
|
139
|
-
}
|
|
140
|
-
return out;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (t === 'array') {
|
|
144
|
-
if (actualType !== 'array') {
|
|
145
|
-
out.push({ jsonPath: path, expected: 'array', actual: actualType, reason: 'type-mismatch' });
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
|
-
const item = schema.items;
|
|
149
|
-
if (item && value.length > 0) {
|
|
150
|
-
// Validate first 10 items only — keeps reports tractable for large arrays.
|
|
151
|
-
const sample = value.slice(0, 10);
|
|
152
|
-
for (let i = 0; i < sample.length; i++) {
|
|
153
|
-
validateAgainstSchema(sample[i], item, `${path}[${i}]`, out, ignore);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return out;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (t === 'integer' || t === 'number') {
|
|
160
|
-
if (actualType !== 'number') {
|
|
161
|
-
out.push({ jsonPath: path, expected: String(t), actual: actualType, reason: 'type-mismatch' });
|
|
162
|
-
}
|
|
163
|
-
return out;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (t === 'string') {
|
|
167
|
-
if (actualType !== 'string') {
|
|
168
|
-
out.push({ jsonPath: path, expected: 'string', actual: actualType, reason: 'type-mismatch' });
|
|
169
|
-
return out;
|
|
170
|
-
}
|
|
171
|
-
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
|
|
172
|
-
out.push({ jsonPath: path, expected: `enum ${JSON.stringify(schema.enum)}`, actual: JSON.stringify(value), reason: 'enum-mismatch' });
|
|
173
|
-
}
|
|
174
|
-
return out;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (t === 'boolean') {
|
|
178
|
-
if (actualType !== 'boolean') {
|
|
179
|
-
out.push({ jsonPath: path, expected: 'boolean', actual: actualType, reason: 'type-mismatch' });
|
|
180
|
-
}
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// No type constraint → pass.
|
|
185
|
-
return out;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function jsType(v: any): string {
|
|
189
|
-
if (v === null) return 'null';
|
|
190
|
-
if (Array.isArray(v)) return 'array';
|
|
191
|
-
return typeof v;
|
|
192
|
-
}
|
|
193
|
-
|