Migrate UWP source from your own tools
Send a UWP file — a page code-behind, a view model, a XAML page, a .csproj,
or a fragment of any of them — and get back the same code rewritten for
WinUI 3 / Windows App SDK, with every legacy API replaced by its modern equivalent, plus a
findings list that marks each of the nine known migration traps MUST-FIX, CHECK or CLEAN with
the evidence from your code, the manual steps the rewrite cannot perform, the behaviour
differences that survive a correct rewrite, and a verdict with a confidence score. Everything
this app does goes through the SkillSafe App API — JSON in over HTTPS, plain text out
— so you can run it over a directory of legacy files, wire it into a migration branch's
CI, or fail a build when a file still comes back Needs redesign. Every step
below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once
and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api. The routes are
/guest, /me, /estimate, /run and
/run-stream — there is no /apps/{slug}/ path
segment anywhere. The app slug winui-migrator appears exactly once, in the body
of POST /guest, and from then on it is bound to the token you were issued.
Every request sends Authorization: Bearer <token> and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on
failure. Estimates are free; runs are metered against your credit balance. There is a single
run task — one paste of code in, one migration report out, no follow-up calls and no
session state to carry.
The request body is the input object itself. Both /estimate and
/run take {"code": …, "notes": …, "legacyscan": …}
at the top level. It is not wrapped in {"input": …}. A wrapped body
is worse than an error: the wrapper is simply not a field this app reads, so
code arrives empty and you get a priced estimate — or a billed run —
against no code at all, with no complaint from the API.
| Status | Error code | Meaning |
|---|---|---|
400 | validation_error | The body failed validation — most often a non-string field or malformed JSON. Note that wrapping the input in {"input": …} does not raise this: it is accepted and read as an empty input, so check your body shape rather than waiting for an error. |
401 | unauthorized | Missing, malformed or expired token — mint a new one with POST /guest or sign in again. |
402 | payment_required | Not enough credits to place the hold — top up at skillsafe.ai/account/credits, or check /estimate first. |
404 | not_found | Unknown job id, or a route that does not exist (check you did not add an /apps/… segment). |
429 | rate_limited | Too many requests — back off and retry. Reuse the same Idempotency-Key on the retry so the run cannot be billed twice. |
5xx | internal_error | Transient platform error — retry with backoff and the same idempotency key. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1 — load it from your secret store in real code
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
err = payload.get("error", {})
raise RuntimeError(f'{err.get("code", res.status_code)}: {err.get("message", res.reason)}')
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — load it from your secret store in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error?.code ?? res.status}: ${json.error?.message ?? res.statusText}`);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s: %s", method, path, env.Error.Code, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil, extra = {})
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless res.is_a?(Net::HTTPSuccess)
raise "#{payload.dig("error", "code")}: #{payload.dig("error", "message") || res.message}"
end
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null, array $extra = []): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge([
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
], $extra),
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception(($payload["error"]["code"] ?? "http_$status") . ": "
. ($payload["error"]["message"] ?? "request failed"));
}
return $payload["data"];
}
// .NET 8+ — the migration client you would actually keep in the repo.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace WinUiMigrator;
public sealed class SkillSafeException(string code, string message)
: Exception($"{code}: {message}")
{
public string Code { get; } = code;
}
public sealed class SkillSafeClient : IDisposable
{
private const string BaseUrl = "https://api.skillsafe.ai/v1/app-api";
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };
/// <summary>Exposed so the streaming helper in step 5 can share the connection.</summary>
internal HttpClient Http => _http;
public SkillSafeClient(string token) =>
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
public async Task<JsonElement> SendAsync(
HttpMethod method, string path, object? body = null,
string? idempotencyKey = null, CancellationToken ct = default)
{
using var req = new HttpRequestMessage(method, BaseUrl + path);
if (body is not null) req.Content = JsonContent.Create(body);
if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey);
using var res = await _http.SendAsync(req, ct);
var env = await res.Content.ReadFromJsonAsync<JsonElement>(ct);
if (!res.IsSuccessStatusCode)
{
var err = env.TryGetProperty("error", out var e) ? e : default;
throw new SkillSafeException(
err.ValueKind is JsonValueKind.Object ? err.GetProperty("code").GetString()! : $"http_{(int)res.StatusCode}",
err.ValueKind is JsonValueKind.Object ? err.GetProperty("message").GetString()! : res.ReasonPhrase ?? "request failed");
}
return env.GetProperty("data");
}
public void Dispose() => _http.Dispose();
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered migration runs
billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on
your clipboard, ready for the examples below. Treat the token like a password: it can spend
your credits. For fully headless scripts, POST /guest mints a guest token with
no browser involved — this is the one and only place the slug is sent.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"winui-migrator"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "winui-migrator"})["token"]
const { token } = await api("POST", "/guest", { slug: "winui-migrator" });
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
err := call("POST", "/guest", map[string]string{"slug": "winui-migrator"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"winui-migrator"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "winui-migrator" })["token"]
$token = api("POST", "/guest", ["slug" => "winui-migrator"])["token"];
// A guest token needs no Authorization header, so mint it with a bare client.
using var bootstrap = new HttpClient();
var res = await bootstrap.PostAsJsonAsync(
"https://api.skillsafe.ai/v1/app-api/guest",
new { slug = "winui-migrator" });
res.EnsureSuccessStatusCode();
var envelope = await res.Content.ReadFromJsonAsync<JsonElement>();
var token = envelope.GetProperty("data").GetProperty("token").GetString()!;
using var client = new SkillSafeClient(token);
The app stores this browser's token under the localStorage key
skillsafe_app_token:winui-migrator, on the app's own origin, and remembers the
guest id under skillsafe_guest:winui-migrator so a later sign-in can carry the
guest wallet over. The token page reads and manages both for you
— you never need to open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Check this before pushing a
large file through — a whole page code-behind plus its XAML comes back as a full
rewrite, which is a long reply.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
fmt.Printf("%s %s: %d credits\n", me.SubjectType, me.SubjectID, me.Credits)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await client.SendAsync(HttpMethod.Get, "/me");
var subjectType = me.GetProperty("subject_type").GetString();
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{subjectType} {me.GetProperty("subject_id").GetString()}: {credits:N0} credits");
Step 3 — Estimate the cost
Send exactly the input you would send to /run — the input object itself,
at the top level of the body. The response's hold_credits is the worst-case cost
and model names the model that will do the work. Nothing is charged and no job
is created, so estimating is free — which is what makes it safe to price a whole
directory of legacy files before spending anything.
| Input field | Type | Notes |
|---|---|---|
code | string, required | The UWP source to migrate: C#, XAML, or a mix (a page's XAML followed by its code-behind), possibly a .csproj. A whole file works best; a fragment migrates too. The web UI clips this at 40,000 characters by removing whole lines from the middle of the file and leaving a bracketed marker in their place that says how many lines went — a source file carries its meaning at both ends, so a head-only cut discards the very methods being migrated. Clip the same way when you cut a file yourself: leave a bracketed marker where the removed lines were, and the reply will say in its summary that the input was cut instead of pretending the rewrite is complete. An empty code is a 400. |
notes | string, optional | What you know about the app that the code does not show: packaged or unpackaged, single-window or multi-window, which window owns this page, the target framework. This genuinely changes the answer — pickers, dialogs and settings storage all migrate differently depending on it. Anything you leave out comes back in ## Open questions rather than as a silent assumption. The web UI clips it at 4,000 characters. |
legacyscan | string, optional | The browser-side scan summary, as a plain-text digest — a scanned N lines, M matches across K patterns header followed by one - [SEVERITY rule-id] where: title -> fix line per match. It is regular-expression matching with no understanding of the code, so it is treated as a hint to verify, never as fact: matches inside comments and string literals are reported too, and an unconfirmable item is never repeated back. Omit it, or send the empty string, and the migration is unaffected. |
retry_note | string, optional | Only set by the app's automatic reformat retry, when a first reply failed to parse. It re-states the required reply shape and must never carry information about the code — it changes the layout of the reply, never the findings, the verdict or the confidence. Leave it out. |
The body is the object above, sent directly. Do not wrap it: with
{"input": {"code": …}} the API sees no code field at all and
answers about an empty input instead of rejecting the call.
cat > EditorPage.cs.txt <<'CODE'
using Windows.UI.Xaml.Controls;
using Windows.UI.Popups;
using Windows.Storage.Pickers;
public sealed partial class EditorPage : Page
{
private async void Save_Click(object sender, RoutedEventArgs e)
{
var picker = new FileSavePicker();
var file = await picker.PickSaveFileAsync();
if (file == null) return;
await Dispatcher.RunAsync(CoreDispatcherPriority.Normal,
() => Status.Text = "Saved " + file.Name);
await new MessageDialog("Saved.", "Editor").ShowAsync();
}
}
CODE
cat > scan.txt <<'SCAN'
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
SCAN
# the input object IS the body — no {"input": ...} wrapper
jq -n --rawfile code EditorPage.cs.txt --rawfile scan scan.txt \
'{code: $code,
notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
legacyscan: $scan}' > input.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data | {hold_credits, model}'
CODE = """using Windows.UI.Xaml.Controls;
using Windows.UI.Popups;
using Windows.Storage.Pickers;
public sealed partial class EditorPage : Page
{
private async void Save_Click(object sender, RoutedEventArgs e)
{
var picker = new FileSavePicker();
var file = await picker.PickSaveFileAsync();
if (file == null) return;
await Dispatcher.RunAsync(CoreDispatcherPriority.Normal,
() => Status.Text = "Saved " + file.Name);
await new MessageDialog("Saved.", "Editor").ShowAsync();
}
}
"""
SCAN = """scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow"""
# the input object IS the body — no {"input": ...} wrapper
payload = {
"code": CODE,
"notes": "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
"legacyscan": SCAN,
}
est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits on", est["model"])
import { readFileSync } from "node:fs";
const code = readFileSync("EditorPage.xaml.cs", "utf8");
const scan = [
"scanned 17 lines, 4 matches across 4 patterns",
"- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml",
"- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue",
"- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot",
"- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow",
].join("\n");
// clip long files the way the web UI does: cut the MIDDLE, keep both ends.
// A source file carries its meaning at the top (usings, class declaration) AND
// at the bottom (the methods that use the legacy APIs), so a head-only slice
// throws away the code you are migrating.
const CODE_LIMIT = 40000;
function clip(src) {
if (src.length <= CODE_LIMIT) return src;
const lines = src.split("\n");
const head = [], tail = [];
let used = 0, i = 0, j = lines.length - 1;
for (; i < lines.length && used + lines[i].length + 1 <= CODE_LIMIT * 0.6; i++) {
head.push(lines[i]); used += lines[i].length + 1;
}
for (used = 0; j >= i && used + lines[j].length + 1 <= CODE_LIMIT * 0.4; j--) {
tail.unshift(lines[j]); used += lines[j].length + 1;
}
const gone = lines.length - head.length - tail.length;
return head.join("\n") +
"\n// [" + gone + " lines were removed from the MIDDLE of this file to fit the " +
"input limit. The lines above and below are verbatim and complete.]\n" +
tail.join("\n");
}
const clipped = clip(code);
// the input object IS the body — no { input: ... } wrapper
const payload = {
code: clipped,
notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
legacyscan: scan,
};
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model);
codeBytes, err := os.ReadFile("EditorPage.xaml.cs")
if err != nil {
log.Fatal(err)
}
// Cut the MIDDLE, not the tail: a head-only slice keeps the usings and
// discards the methods that actually use the legacy APIs.
const codeLimit = 40000
code := string(codeBytes)
if len(code) > codeLimit {
head := codeLimit * 6 / 10
tail := codeLimit - head
code = code[:head] +
"\n// [the MIDDLE of this file was removed to fit the input limit; " +
"the lines above and below are verbatim and complete]\n" +
code[len(code)-tail:]
}
scan := "scanned 17 lines, 4 matches across 4 patterns\n" +
"- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml\n" +
"- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue\n" +
"- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot\n" +
"- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow"
// the input object IS the body — no {"input": ...} wrapper
payload := map[string]any{
"code": code,
"notes": "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
"legacyscan": scan,
}
var est struct {
HoldCredits int64 `json:"hold_credits"`
Model string `json:"model"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
log.Fatal(err)
}
fmt.Printf("worst case: %d credits on %s\n", est.HoldCredits, est.Model)
String code = Files.readString(Path.of("EditorPage.xaml.cs"));
// Cut the MIDDLE, not the tail — keep the top and the bottom of the file.
final int CODE_LIMIT = 40_000;
if (code.length() > CODE_LIMIT) {
int head = CODE_LIMIT * 6 / 10, tail = CODE_LIMIT - head;
code = code.substring(0, head)
+ "\n// [the MIDDLE of this file was removed to fit the input limit; "
+ "the lines above and below are verbatim and complete]\n"
+ code.substring(code.length() - tail);
}
String scan = """
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow""";
// the input object IS the body — no {"input": ...} wrapper.
// toJsonString() is your JSON library's string escaper.
String jsonPayload = """
{"code": %s,
"notes": "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
"legacyscan": %s}
""".formatted(toJsonString(code), toJsonString(scan));
String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits, the model at data.model
CODE_LIMIT = 40_000
code = File.read("EditorPage.xaml.cs")
# Cut the MIDDLE, not the tail — keep the top and the bottom of the file.
if code.length > CODE_LIMIT
head = CODE_LIMIT * 6 / 10
tail = CODE_LIMIT - head
code = code[0, head] +
"\n# [the MIDDLE of this file was removed to fit the input limit; " \
"the lines above and below are verbatim and complete]\n" +
code[-tail..]
end
scan = <<~SCAN.chomp
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
SCAN
# the input object IS the body — no { input: ... } wrapper
payload = { code: code,
notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
legacyscan: scan }
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
const CODE_LIMIT = 40000;
$code = file_get_contents("EditorPage.xaml.cs");
// Cut the MIDDLE, not the tail — keep the top and the bottom of the file.
if (strlen($code) > CODE_LIMIT) {
$head = intdiv(CODE_LIMIT * 6, 10);
$tail = CODE_LIMIT - $head;
$code = substr($code, 0, $head)
. "\n// [the MIDDLE of this file was removed to fit the input limit; "
. "the lines above and below are verbatim and complete]\n"
. substr($code, -$tail);
}
$scan = <<<'SCAN'
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
SCAN;
// the input object IS the body — no ["input" => ...] wrapper
$payload = [
"code" => $code,
"notes" => "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
"legacyscan" => $scan,
];
$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
// The request body, as a record — the input object IS the body.
// Omitted members are simply left out of the JSON; RetryNote is set only by
// the app's own reformat retry, so it stays null here.
public sealed record MigrationInput(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("notes")] string Notes = "",
[property: JsonPropertyName("legacyscan")] string LegacyScan = "",
[property: JsonPropertyName("retry_note")]
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
string? RetryNote = null)
{
public const int CodeLimit = 40_000;
/// <summary>
/// Clips like the web UI does: the cut goes through the MIDDLE of the file.
/// A head-only slice keeps the usings and discards the methods that use the
/// legacy APIs, which is precisely the code being migrated.
/// </summary>
public static MigrationInput FromFile(string path, string notes = "", string scan = "")
{
var code = File.ReadAllText(path);
if (code.Length > CodeLimit)
{
var head = CodeLimit * 6 / 10;
var tail = CodeLimit - head;
code = string.Concat(
code.AsSpan(0, head),
"\n// [the MIDDLE of this file was removed to fit the input limit; " +
"the lines above and below are verbatim and complete]\n",
code.AsSpan(code.Length - tail));
}
return new MigrationInput(code, notes, scan);
}
}
var input = MigrationInput.FromFile(
"EditorPage.xaml.cs",
notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
scan: """
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
""");
var est = await client.SendAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits").GetInt64():N0} credits " +
$"on {est.GetProperty("model").GetString()}");
legacyscan is a hint, not an instruction. Every item is verified against the
code before it is acted on, a match inside a comment or a string literal is discarded, and
an item that cannot be confirmed is never echoed back as a finding. Equally, a clean scan is
never treated as proof the code is clean — all nine findings are assessed on every run
whatever you send here.
Step 4 — Run the migration and wait for the result
/run takes the same input as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until
status is succeeded or failed. A run typically takes
30–120 s, because the reply carries your entire file rewritten, not a diff.
Always send an Idempotency-Key header so a network retry, a
429 back-off or a crashed worker cannot start a second, double-charged run:
replaying the same key returns the original job instead of billing again. The reply is in
output — usually nested as output.output — and it is
plain text, not JSON. Its exact grammar is the next section.
IDEM="wm-$(date +%s)-$RANDOM"
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
[ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq -r '.data.error'; exit 1; }
# plain text, not JSON — unwrap once and keep the whole report
echo "$JOB" | jq -r '.data.output.output' > report.md
# the three header lines
sed -n '1,3p' report.md
# the migrated code: everything inside the fences under "## Migrated code"
awk '/^## Migrated code$/{s=1;next} /^## /{s=0} s' report.md \
| awk '/^```/{f=!f;next} f' > EditorPage.migrated.cs
# a non-empty Manual steps section forbids a Drop-in rewrite verdict
awk '/^## Manual steps$/{s=1;next} /^## /{s=0} s && /^- /' report.md
grep -q '^VERDICT: Needs redesign$' report.md && { echo "redesign required"; exit 1; }
exit 0
import re, time, uuid
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": str(uuid.uuid4())})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"] # plain text, NOT json.loads()
SECTIONS = ["Findings", "Migrated code", "Manual steps",
"Compatibility notes", "Open questions"]
def parse(report):
verdict = re.search(r"^VERDICT:\s*(.+)$", report, re.M).group(1).strip()
confidence = int(re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", report, re.M).group(1))
summary = re.search(r"^SUMMARY:\s*(.*?)(?:\n\s*\n|\n## )", report, re.M | re.S)
body = {}
for name in SECTIONS:
m = re.search(rf"^## {re.escape(name)}\s*$(.*?)(?=^## |\Z)", report, re.M | re.S)
body[name] = m.group(1).strip() if m else ""
return {
"verdict": verdict,
"confidence": confidence,
"summary": " ".join(summary.group(1).split()),
"sections": body,
"blocks": re.findall(r"```([A-Za-z0-9+#-]*)\n(.*?)```", body["Migrated code"], re.S),
}
def bullets(text):
items = [ln[2:].strip() for ln in text.splitlines() if ln.startswith("- ")]
return [] if items == ["None."] else items
report = parse(raw)
print(f'{report["verdict"]} ({report["confidence"]}%) — {report["summary"]}')
for line in bullets(report["sections"]["Findings"]):
print(" finding:", line) # "<Name> - MUST-FIX|CHECK|CLEAN: <evidence>"
for step in bullets(report["sections"]["Manual steps"]):
print(" manual:", step)
for note in bullets(report["sections"]["Compatibility notes"]):
print(" note:", note)
for q in bullets(report["sections"]["Open questions"]):
print(" open:", q)
with open("report.md", "w", encoding="utf-8") as fh:
fh.write(raw)
for i, (lang, code) in enumerate(report["blocks"]):
ext = {"csharp": "cs", "xaml": "xaml", "xml": "xml"}.get(lang, "txt")
with open(f"migrated_{i}.{ext}", "w", encoding="utf-8") as fh:
fh.write(code)
# the contract's own consistency rule, worth asserting on
assert not (report["verdict"] == "Drop-in rewrite"
and bullets(report["sections"]["Manual steps"])), "inconsistent verdict"
import { writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": randomUUID() });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
// plain text, NOT JSON.parse()
const raw = typeof job.output === "string" ? job.output : job.output.output;
const SECTIONS = ["Findings", "Migrated code", "Manual steps",
"Compatibility notes", "Open questions"];
function parse(report) {
const sections = {};
for (const name of SECTIONS) {
const re = new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, "m");
sections[name] = (re.exec(report)?.[1] ?? "").trim();
}
const blocks = [...sections["Migrated code"]
.matchAll(/```([A-Za-z0-9+#-]*)\n([\s\S]*?)```/g)]
.map(([, lang, code]) => ({ lang: lang || "code", code }));
return {
verdict: /^VERDICT:\s*(.+)$/m.exec(report)[1].trim(),
confidence: Number(/^CONFIDENCE:\s*(\d{1,3})\s*$/m.exec(report)[1]),
summary: /^SUMMARY:\s*([\s\S]*?)(?:\n\s*\n|\n## )/m.exec(report)[1]
.split(/\s+/).join(" ").trim(),
sections,
blocks,
};
}
const bullets = (text) => {
const items = text.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim());
return items.length === 1 && items[0] === "None." ? [] : items;
};
const report = parse(raw);
console.log(`${report.verdict} (${report.confidence}%) — ${report.summary}`);
for (const f of bullets(report.sections["Findings"])) console.log(" finding:", f);
for (const s of bullets(report.sections["Manual steps"])) console.log(" manual:", s);
for (const n of bullets(report.sections["Compatibility notes"])) console.log(" note:", n);
for (const q of bullets(report.sections["Open questions"])) console.log(" open:", q);
writeFileSync("report.md", raw);
report.blocks.forEach((b, i) => {
const ext = { csharp: "cs", xaml: "xaml", xml: "xml" }[b.lang] ?? "txt";
writeFileSync(`migrated_${i}.${ext}`, b.code);
});
if (report.verdict === "Needs redesign") process.exitCode = 1;
idem := uuid.NewString() // any unique, stable-per-attempt string
var started struct {
JobID string `json:"job_id"`
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
// ...send it and decode data.job_id into started (same envelope as call()).
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
log.Fatal(job.Error)
}
report := job.Output.Output // plain text, not JSON
var (
verdictRe = regexp.MustCompile(`(?m)^VERDICT:\s*(.+)$`)
confRe = regexp.MustCompile(`(?m)^CONFIDENCE:\s*(\d{1,3})\s*$`)
fenceRe = regexp.MustCompile("(?s)```([A-Za-z0-9+#-]*)\n(.*?)```")
)
// section returns the body under a "## Name" heading.
func section(report, name string) string {
re := regexp.MustCompile(`(?ms)^## ` + regexp.QuoteMeta(name) + `\s*$(.*?)(?:^## |\z)`)
m := re.FindStringSubmatch(report)
if m == nil {
return ""
}
return strings.TrimSpace(m[1])
}
func bullets(body string) []string {
var out []string
for _, ln := range strings.Split(body, "\n") {
if strings.HasPrefix(ln, "- ") {
out = append(out, strings.TrimSpace(ln[2:]))
}
}
if len(out) == 1 && out[0] == "None." {
return nil
}
return out
}
verdict := strings.TrimSpace(verdictRe.FindStringSubmatch(report)[1])
confidence, _ := strconv.Atoi(confRe.FindStringSubmatch(report)[1])
fmt.Printf("%s (%d%%)\n", verdict, confidence)
for _, f := range bullets(section(report, "Findings")) {
fmt.Println(" finding:", f) // "<Name> - MUST-FIX|CHECK|CLEAN: <evidence>"
}
for _, s := range bullets(section(report, "Manual steps")) {
fmt.Println(" manual:", s)
}
os.WriteFile("report.md", []byte(report), 0o644)
for i, m := range fenceRe.FindAllStringSubmatch(section(report, "Migrated code"), -1) {
ext := map[string]string{"csharp": "cs", "xaml": "xaml", "xml": "xml"}[m[1]]
if ext == "" {
ext = "txt"
}
os.WriteFile(fmt.Sprintf("migrated_%d.%s", i, ext), []byte(m[2]), 0o644)
}
if verdict == "Needs redesign" {
os.Exit(1)
}
import java.util.regex.*;
String startEnvelope = api("POST", "/run", jsonPayload); // add the header below
// Send POST /run with an "Idempotency-Key" header — UUID.randomUUID().toString()
// is fine — so a retry cannot start a second, double-charged run.
String jobId = /* data.job_id via your JSON library */;
String job;
String status;
while (true) {
job = api("GET", "/jobs/" + jobId, null);
status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
if (status.equals("failed")) throw new RuntimeException(/* data.error */);
// data.output.output is PLAIN TEXT — do not parse it as JSON.
String report = /* data.output.output */;
Matcher mv = Pattern.compile("^VERDICT:\\s*(.+)$", Pattern.MULTILINE).matcher(report);
Matcher mc = Pattern.compile("^CONFIDENCE:\\s*(\\d{1,3})\\s*$", Pattern.MULTILINE).matcher(report);
mv.find();
mc.find();
String verdict = mv.group(1).trim();
int confidence = Integer.parseInt(mc.group(1));
// Body under a "## Name" heading.
java.util.function.BiFunction<String, String, String> section = (rep, name) -> {
Matcher m = Pattern.compile("^## " + Pattern.quote(name) + "\\s*$(.*?)(?=^## |\\z)",
Pattern.MULTILINE | Pattern.DOTALL).matcher(rep);
return m.find() ? m.group(1).strip() : "";
};
List<String> manual = section.apply(report, "Manual steps").lines()
.filter(l -> l.startsWith("- ")).map(l -> l.substring(2).strip()).toList();
if (manual.equals(List.of("None."))) manual = List.of();
System.out.printf("%s (%d%%)%n", verdict, confidence);
manual.forEach(s -> System.out.println(" manual: " + s));
// The fenced blocks under "## Migrated code" are the rewritten files, in input order.
Matcher fence = Pattern.compile("```([A-Za-z0-9+#-]*)\\n(.*?)```", Pattern.DOTALL)
.matcher(section.apply(report, "Migrated code"));
int i = 0;
Files.writeString(Path.of("report.md"), report);
while (fence.find()) {
String ext = switch (fence.group(1)) {
case "csharp" -> "cs";
case "xaml" -> "xaml";
case "xml" -> "xml";
default -> "txt";
};
Files.writeString(Path.of("migrated_" + (i++) + "." + ext), fence.group(2));
}
require "securerandom"
started = api("POST", "/run", payload,
{ "Idempotency-Key" => SecureRandom.uuid })
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
# plain text, not JSON
report = job["output"].is_a?(Hash) ? job["output"]["output"] : job["output"]
def section(report, name)
m = report.match(/^## #{Regexp.escape(name)}\s*$(.*?)(?=^## |\z)/m)
m ? m[1].strip : ""
end
def bullets(body)
items = body.lines.select { |l| l.start_with?("- ") }.map { |l| l[2..].strip }
items == ["None."] ? [] : items
end
verdict = report[/^VERDICT:\s*(.+)$/, 1].strip
confidence = report[/^CONFIDENCE:\s*(\d{1,3})\s*$/, 1].to_i
summary = report[/^SUMMARY:\s*(.*?)(?:\n\s*\n|\n## )/m, 1].split.join(" ")
puts "#{verdict} (#{confidence}%) — #{summary}"
bullets(section(report, "Findings")).each { |f| puts " finding: #{f}" }
bullets(section(report, "Manual steps")).each { |s| puts " manual: #{s}" }
bullets(section(report, "Compatibility notes")).each { |n| puts " note: #{n}" }
bullets(section(report, "Open questions")).each { |q| puts " open: #{q}" }
File.write("report.md", report)
exts = { "csharp" => "cs", "xaml" => "xaml", "xml" => "xml" }
section(report, "Migrated code").scan(/```([A-Za-z0-9+#-]*)\n(.*?)```/m).each_with_index do |(lang, code), i|
File.write("migrated_#{i}.#{exts.fetch(lang, "txt")}", code)
end
exit 1 if verdict == "Needs redesign"
$started = api("POST", "/run", $payload,
["Idempotency-Key: " . bin2hex(random_bytes(16))]);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
// plain text, not JSON
$report = is_array($job["output"]) ? $job["output"]["output"] : $job["output"];
function section(string $report, string $name): string {
$re = "/^## " . preg_quote($name, "/") . '\s*$(.*?)(?=^## |\z)/ms';
return preg_match($re, $report, $m) ? trim($m[1]) : "";
}
function bullets(string $body): array {
$items = [];
foreach (explode("\n", $body) as $line) {
if (str_starts_with($line, "- ")) { $items[] = trim(substr($line, 2)); }
}
return $items === ["None."] ? [] : $items;
}
preg_match('/^VERDICT:\s*(.+)$/m', $report, $mv);
preg_match('/^CONFIDENCE:\s*(\d{1,3})\s*$/m', $report, $mc);
preg_match('/^SUMMARY:\s*(.*?)(?:\n\s*\n|\n## )/ms', $report, $msum);
$verdict = trim($mv[1]);
echo "{$verdict} ({$mc[1]}%) — " . preg_replace('/\s+/', " ", trim($msum[1])) . "\n";
foreach (bullets(section($report, "Findings")) as $f) { echo " finding: $f\n"; }
foreach (bullets(section($report, "Manual steps")) as $s) { echo " manual: $s\n"; }
foreach (bullets(section($report, "Compatibility notes")) as $n) { echo " note: $n\n"; }
foreach (bullets(section($report, "Open questions")) as $q) { echo " open: $q\n"; }
file_put_contents("report.md", $report);
preg_match_all('/```([A-Za-z0-9+#-]*)\n(.*?)```/s', section($report, "Migrated code"), $blocks, PREG_SET_ORDER);
$exts = ["csharp" => "cs", "xaml" => "xaml", "xml" => "xml"];
foreach ($blocks as $i => $b) {
file_put_contents("migrated_$i." . ($exts[$b[1]] ?? "txt"), $b[2]);
}
exit($verdict === "Needs redesign" ? 1 : 0);
using System.Text.RegularExpressions;
// The reply contract, decoded once, in one place — mirror of the app's own parser.
public enum Verdict { DropInRewrite, RewriteWithManualSteps, NeedsRedesign }
public sealed record CodeBlock(string Language, string Code)
{
public string Extension => Language switch
{
"csharp" or "cs" => "cs",
"xaml" => "xaml",
"xml" => "xml",
_ => "txt",
};
}
public sealed record Finding(string Name, string Status, string Evidence);
public sealed partial class MigrationReport
{
private static readonly string[] SectionNames =
["Findings", "Migrated code", "Manual steps", "Compatibility notes", "Open questions"];
[GeneratedRegex(@"^VERDICT:\s*(.+)$", RegexOptions.Multiline)]
private static partial Regex VerdictRegex();
[GeneratedRegex(@"^CONFIDENCE:\s*(\d{1,3})\s*$", RegexOptions.Multiline)]
private static partial Regex ConfidenceRegex();
[GeneratedRegex(@"^SUMMARY:\s*([\s\S]*?)(?:\n\s*\n|\n## )", RegexOptions.Multiline)]
private static partial Regex SummaryRegex();
[GeneratedRegex(@"```([A-Za-z0-9+#-]*)\n([\s\S]*?)```")]
private static partial Regex FenceRegex();
[GeneratedRegex(@"^(.*?)\s+-\s+(MUST-FIX|CHECK|CLEAN):\s*(.*)$")]
private static partial Regex FindingRegex();
public required Verdict Verdict { get; init; }
public required int Confidence { get; init; }
public required string Summary { get; init; }
public required IReadOnlyList<Finding> Findings { get; init; }
public required IReadOnlyList<CodeBlock> Blocks { get; init; }
public required IReadOnlyList<string> ManualSteps { get; init; }
public required IReadOnlyList<string> CompatibilityNotes { get; init; }
public required IReadOnlyList<string> OpenQuestions { get; init; }
public required string Raw { get; init; }
/// <summary>A non-empty Manual steps section forbids "Drop-in rewrite".</summary>
public bool IsConsistent =>
Verdict is not Verdict.DropInRewrite || ManualSteps.Count == 0;
public static MigrationReport Parse(string report)
{
var sections = SectionNames.ToDictionary(
name => name,
name => Regex.Match(report,
$@"^## {Regex.Escape(name)}\s*$([\s\S]*?)(?=^## |\z)",
RegexOptions.Multiline) is { Success: true } m ? m.Groups[1].Value.Trim() : "");
static IReadOnlyList<string> Bullets(string body)
{
var items = body.Split('\n')
.Where(l => l.StartsWith("- ", StringComparison.Ordinal))
.Select(l => l[2..].Trim())
.ToArray();
return items is ["None."] ? [] : items;
}
var findings = Bullets(sections["Findings"]).Select(line =>
{
var m = FindingRegex().Match(line);
return m.Success
? new Finding(m.Groups[1].Value.Trim(), m.Groups[2].Value, m.Groups[3].Value.Trim())
: new Finding(line, "NOTED", "");
}).ToArray();
return new MigrationReport
{
Verdict = VerdictRegex().Match(report).Groups[1].Value.Trim() switch
{
"Drop-in rewrite" => Verdict.DropInRewrite,
"Rewrite with manual steps" => Verdict.RewriteWithManualSteps,
"Needs redesign" => Verdict.NeedsRedesign,
var other => throw new FormatException($"unknown verdict: {other}"),
},
Confidence = int.Parse(ConfidenceRegex().Match(report).Groups[1].ValueSpan),
Summary = string.Join(' ', SummaryRegex().Match(report).Groups[1].Value.Split(
(char[]?)null, StringSplitOptions.RemoveEmptyEntries)),
Findings = findings,
Blocks = FenceRegex().Matches(sections["Migrated code"])
.Select(m => new CodeBlock(
m.Groups[1].Value is { Length: > 0 } lang ? lang.ToLowerInvariant() : "code",
m.Groups[2].Value))
.ToArray(),
ManualSteps = Bullets(sections["Manual steps"]),
CompatibilityNotes = Bullets(sections["Compatibility notes"]),
OpenQuestions = Bullets(sections["Open questions"]),
Raw = report,
};
}
}
// --- run it -----------------------------------------------------------------
var started = await client.SendAsync(HttpMethod.Post, "/run", input,
idempotencyKey: Guid.NewGuid().ToString()); // a retry must never double-bill
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await client.SendAsync(HttpMethod.Get, $"/jobs/{jobId}");
if (job.GetProperty("status").GetString() is "succeeded" or "failed") break;
await Task.Delay(TimeSpan.FromSeconds(1.5));
}
if (job.GetProperty("status").GetString() == "failed")
throw new InvalidOperationException(job.GetProperty("error").GetString());
// output.output is PLAIN TEXT — never JsonDocument.Parse it.
var raw = job.GetProperty("output").GetProperty("output").GetString()!;
var report = MigrationReport.Parse(raw);
Console.WriteLine($"{report.Verdict} ({report.Confidence}%) — {report.Summary}");
foreach (var f in report.Findings)
Console.WriteLine($" [{f.Status,8}] {f.Name}: {f.Evidence}");
foreach (var step in report.ManualSteps) Console.WriteLine($" manual: {step}");
foreach (var note in report.CompatibilityNotes) Console.WriteLine($" note: {note}");
foreach (var q in report.OpenQuestions) Console.WriteLine($" open: {q}");
await File.WriteAllTextAsync("report.md", report.Raw);
foreach (var (block, i) in report.Blocks.Select((b, i) => (b, i)))
await File.WriteAllTextAsync($"migrated_{i}.{block.Extension}", block.Code);
if (!report.IsConsistent)
Console.Error.WriteLine("warning: Drop-in rewrite with manual steps — re-run.");
return report.Verdict is Verdict.NeedsRedesign ? 1 : 0;
The reply is asked for as bare text with nothing before VERDICT: and no fence
around the whole response, but a stray wrapper is always possible. Strip a leading
``` fence line and its trailing partner before parsing — that is what the
app does before it falls back to a retry_note reformat run.
The reply — output contract
The output is plain text, not JSON. It is three tagged header lines followed
by five ## sections, always all five and always in this order. Anything that
breaks the grammar below is a failed parse, and the app retries once with the shape spelled
out in retry_note.
| Line / section | Grammar |
|---|---|
VERDICT: | The first line. Its value is exactly one of Drop-in rewrite, Rewrite with manual steps or Needs redesign, spelled and capitalized that way. There is no fourth verdict. |
CONFIDENCE: | A bare integer from 0 to 100. No percent sign, no range, no word. It is confidence in the migration, not in the input: lower for a fragment whose surrounding types are not visible, for truncated input, or when packaged-versus-unpackaged is unstated and the answer depends on it. |
SUMMARY: | Two to four sentences. It may wrap over several lines and ends at the first blank line — join the lines with a space when you read it. |
## Findings | One - bullet per finding, in the shape - <Name> - MUST-FIX|CHECK|CLEAN: <evidence>. Always has content — never - None. See the table below. |
## Migrated code | One or more fenced blocks tagged csharp, xml or xaml, one per file or fragment in the input, in input order, each carrying the whole rewritten piece rather than a diff or an elision. When the input contained nothing to migrate, the section is the single bullet - None. instead. A ## line is never nested inside a fence. |
## Manual steps | - bullets, or the single bullet - None. The work outside the code block: retarget the project file, create the App.MainWindow static, move tests to a WinUI 3 test project, decide packaging. |
## Compatibility notes | - bullets, or - None. Behaviour that differs even after a correct rewrite — for example the ASTA reentrancy protection WinUI 3 does not have. |
## Open questions | - bullets, or - None. What was not stated and would change the migration — typically the things left out of notes. |
The consistency rule worth asserting on: a non-empty
## Manual steps section forbids the verdict Drop-in rewrite. There
is no "trivial step" exception. A reply that carries both is self-inconsistent — the
app flags it, and so should your pipeline.
Findings bullets
Nine named findings are assessed on every run, one bullet each, in this order, whatever the
legacyscan said: XAML namespaces, Colors and text, Dispatcher,
Window.Current, MessageDialog, ContentDialog
XamlRoot, pickers and hwnd interop, GetForCurrentView APIs, and
background tasks and lifecycle. Extra bullets may follow the nine when the code contains
another pattern that affects the migration.
| Status | Meaning |
|---|---|
MUST-FIX | The legacy pattern is present in the code and the rewrite changes it. |
CHECK | The pattern is present but cannot be fully resolved from the pasted code alone — the fix depends on the app's window, project shape or packaging — or the API migrates but behaves differently. |
CLEAN | The pattern does not occur, or is already in its WinUI 3 form. |
Every bullet carries its evidence after the colon: the identifier, member or line involved.
A permissive parser should split on the last - before the status word, since a
finding name may itself contain a hyphen.
A small, realistic reply for the snippet above:
VERDICT: Rewrite with manual steps
CONFIDENCE: 82
SUMMARY: Every legacy API in this handler has a direct Windows App SDK equivalent, so the
code migrates in full: the namespaces move to Microsoft.UI.Xaml, Dispatcher.RunAsync becomes
DispatcherQueue.TryEnqueue, and the MessageDialog becomes a ContentDialog with an explicit
XamlRoot. Two things remain outside the file: the FileSavePicker needs the real window handle
wired in, and the project must be retargeted. The notes say the window lives in App.MainWindow,
so that is what the rewrite calls.
## Findings
- XAML namespaces - MUST-FIX: `using Windows.UI.Xaml.Controls;` at line 1, rewritten to
`Microsoft.UI.Xaml.Controls`.
- Colors and text - CLEAN: no `Windows.UI.Colors` or `Windows.UI.Text` reference in the input.
- Dispatcher - MUST-FIX: `Dispatcher.RunAsync(CoreDispatcherPriority.Normal, ...)` at line 13
becomes `DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, ...)`.
- Window.Current - CLEAN: not referenced; the window is reached through `App.MainWindow`.
- MessageDialog - MUST-FIX: `new MessageDialog("Saved.", "Editor")` at line 15 becomes a
`ContentDialog` with Title, Content and a CloseButtonText.
- ContentDialog XamlRoot - MUST-FIX: the new dialog sets `XamlRoot = this.XamlRoot` before
`ShowAsync`, which UWP did not require.
- Pickers and hwnd interop - CHECK: `new FileSavePicker()` at line 9 is initialized with
`App.MainWindow`'s handle; confirm that static exists.
- GetForCurrentView APIs - CLEAN: no `GetForCurrentView()` call in the input.
- Background tasks and lifecycle - CLEAN: no `IBackgroundTask` or `BackgroundTaskBuilder`.
## Migrated code
```csharp
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage.Pickers;
public sealed partial class EditorPage : Page
{
private async void Save_Click(object sender, RoutedEventArgs e)
{
var picker = new FileSavePicker();
WinRT.Interop.InitializeWithWindow.Initialize(
picker, WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow));
var file = await picker.PickSaveFileAsync();
if (file == null) return;
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal,
() => Status.Text = "Saved " + file.Name);
var dialog = new ContentDialog
{
Title = "Editor",
Content = "Saved.",
CloseButtonText = "OK",
XamlRoot = this.XamlRoot,
};
await dialog.ShowAsync();
}
}
```
## Manual steps
- Retarget the project to `net10.0-windows10.0.22621.0` with `<UseWinUI>true</UseWinUI>` and a
Windows App SDK package reference.
- Create or confirm the `App.MainWindow` static that the picker interop and the dialog rely on.
## Compatibility notes
- `DispatcherQueue.TryEnqueue` returns a bool and does not await the queued work, unlike
`Dispatcher.RunAsync` which returned an awaitable operation; sequencing that depended on the
await must be restructured.
- WinUI 3 has no ASTA reentrancy protection, so a modal dialog no longer blocks input to the
rest of the window in the way UWP guaranteed.
## Open questions
- Is this page ever hosted in a second window? `this.XamlRoot` is correct per-window, but the
picker is wired to `App.MainWindow` specifically.
This is an AI-generated migration, not a compiled build: the rewrite is only as good as the
code you pasted, and it cannot see the types, resources and project settings that are not in
the input. Read ## Open questions and every CHECK finding before
you commit, and build the result.
Step 5 — Stream the migration as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events, so you can show progress instead of a spinner — useful here because
the reply carries the whole file rewritten and can run to thousands of characters. This app's
own progress panel is this endpoint. Events are separated by a blank line; each has an
event: line and a data: line carrying JSON.
Send an Idempotency-Key header. The bundled SDK's
runStream(input, opts) sets it from opts.idempotencyKey, and every
sample below sets it by hand. A stream can drop mid-reply for reasons that have nothing to do
with the run; without the key the natural retry starts a second run and bills you twice, and
with it the replay returns the original result. On such a replay the server may answer with a
plain JSON envelope rather than an event stream, so check the Content-Type
before you start parsing frames — the SDK does exactly that.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the plain-text reply, in order. Append it; the accumulated length is your only progress signal, since the total is not known in advance. Watching for the ## headings as they arrive gives you a step list for free. |
done | {job_id, status, charged_credits, output} | The final, authoritative result — read the report from output.output rather than trusting concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
# -N disables buffering so events print as they arrive.
# Reuse the same key on a retry and the run cannot be billed twice.
IDEM="wm-$(date +%s)-$RANDOM"
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"VERDICT: Rewrite with manual steps\nCONFIDENCE: 82\n"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":512,"output":{"output":"VERDICT: ..."}}
import json, uuid, requests
idem = str(uuid.uuid4()) # reuse this exact value on any retry
result = None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": idem},
json=payload, # the input object itself
stream=True,
) as r:
r.raise_for_status()
if "text/event-stream" not in r.headers.get("content-type", ""):
result = r.json()["data"] # idempotent replay
else:
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(f'{data.get("code")}: {data.get("message")}')
raw = result["output"]["output"] # authoritative plain text
report = parse(raw) # the parser from step 4
print(f'\ncharged {result["charged_credits"]} — {report["verdict"]} ({report["confidence"]}%)')
with open("report.md", "w", encoding="utf-8") as fh:
fh.write(raw)
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(), // reuse the same value on a retry
},
body: JSON.stringify(payload), // the input object itself
});
let done;
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) {
done = (await res.json()).data; // idempotent replay
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !body) continue;
const data = JSON.parse(body);
if (name === "delta") process.stdout.write("."); // live progress
if (name === "done") done = data;
if (name === "error") throw new Error(`${data.code}: ${data.message}`);
}
}
}
const raw = done.output.output; // authoritative plain text
const report = parse(raw); // the parser from step 4
console.log(`\n${done.charged_credits} credits — ${report.verdict} (${report.confidence}%)`);
writeFileSync("report.md", raw);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem) // reuse the same value on a retry
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var final map[string]any
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct{ Data map[string]any `json:"data"` }
json.NewDecoder(res.Body).Decode(&env) // idempotent replay
final = env.Data
} else {
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatalf("%v: %v", data["code"], data["message"])
}
}
}
}
report := final["output"].(map[string]any)["output"].(string) // plain text
os.WriteFile("report.md", []byte(report), 0o644)
fmt.Println("\n" + verdictRe.FindStringSubmatch(report)[1]) // parser from step 4
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idem) // reuse the same value on a retry
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
// If the Content-Type is not text/event-stream this was an idempotent replay:
// the body is a plain {"data": ...} envelope, so read data.output.output directly.
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) System.out.print("."); // live progress
else if ("done".equals(event)) done = data;
else if ("error".equals(event)) throw new RuntimeException(data);
}
}
// Parse `done`, take data.output.output — it is PLAIN TEXT, not JSON — then run
// the step-4 parser over it: VERDICT, CONFIDENCE, SUMMARY, then the five
// sections. data.charged_credits is the settled price.
// Files.writeString(Path.of("report.md"), report);
require "net/http"
require "json"
require "securerandom"
idem = SecureRandom.uuid # reuse this exact value on any retry
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem
req.body = payload.to_json # the input object itself
event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
if !res["content-type"].to_s.include?("text/event-stream")
done = JSON.parse(res.body)["data"] # idempotent replay
next
end
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print "." # live progress
when "done" then done = data
when "error" then raise "#{data["code"]}: #{data["message"]}"
end
end
end
end
end
end
report = done["output"]["output"] # authoritative plain text
puts "\n#{done["charged_credits"]} credits - #{report[/^VERDICT:\s*(.+)$/, 1]}"
File.write("report.md", report)
$event = null;
$done = null;
$idem = bin2hex(random_bytes(16)); // reuse the same value on a retry
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: $idem",
],
CURLOPT_POSTFIELDS => json_encode($payload), // the input object itself
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { echo "."; } // live progress
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") {
throw new Exception("{$data['code']}: {$data['message']}");
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$report = $done["output"]["output"]; // authoritative plain text
preg_match('/^VERDICT:\s*(.+)$/m', $report, $mv);
echo "\n{$done['charged_credits']} credits - " . trim($mv[1]) . "\n";
file_put_contents("report.md", $report);
// Streaming run, as an extension on the step-0 client (put it in a static
// class of your own). The idempotency key is what makes a dropped stream safe
// to retry — reuse the same value for the same attempt.
public static async Task<MigrationReport> RunStreamAsync(
this SkillSafeClient client, MigrationInput input, string idempotencyKey,
IProgress<string>? onDelta = null, CancellationToken ct = default)
{
using var req = new HttpRequestMessage(
HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
{
Content = JsonContent.Create(input), // the input object itself
};
req.Headers.Add("Idempotency-Key", idempotencyKey);
using var res = await client.Http.SendAsync(
req, HttpCompletionOption.ResponseHeadersRead, ct);
res.EnsureSuccessStatusCode();
// An idempotent replay answers with a plain envelope, not an event stream.
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
var replay = await res.Content.ReadFromJsonAsync<JsonElement>(ct);
return MigrationReport.Parse(
replay.GetProperty("data").GetProperty("output").GetProperty("output").GetString()!);
}
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync(ct));
string? evt = null;
JsonElement done = default;
while (await reader.ReadLineAsync(ct) is { } line)
{
if (line.StartsWith("event:", StringComparison.Ordinal))
{
evt = line[6..].Trim();
}
else if (line.StartsWith("data:", StringComparison.Ordinal))
{
using var frame = JsonDocument.Parse(line[5..].Trim());
switch (evt)
{
case "delta":
onDelta?.Report(frame.RootElement.GetProperty("text").GetString() ?? "");
break;
case "done":
done = frame.RootElement.Clone();
break;
case "error":
throw new SkillSafeException(
frame.RootElement.GetProperty("code").GetString()!,
frame.RootElement.GetProperty("message").GetString()!);
}
}
}
Console.WriteLine($"charged {done.GetProperty("charged_credits").GetInt64():N0} credits");
// Trust the final payload, not the concatenated deltas.
return MigrationReport.Parse(
done.GetProperty("output").GetProperty("output").GetString()!);
}
// --- use it -----------------------------------------------------------------
var streamed = await client.RunStreamAsync(
input,
idempotencyKey: Guid.NewGuid().ToString(),
onDelta: new Progress<string>(_ => Console.Write(".")));
Console.WriteLine();
Console.WriteLine($"{streamed.Verdict} ({streamed.Confidence}%)");
await File.WriteAllTextAsync("report.md", streamed.Raw);
In a browser, the native EventSource only speaks GET and this endpoint is a
POST — read the fetch response body incrementally, as the JavaScript
sample does. Deltas are for progress only: they can be cut short if a run runs out of
credits mid-reply, so the report you keep is always the one in the done event.