Drive Csproj Modernizer from your own code
The page reads your MSBuild files in the browser for free — .csproj,
.vbproj, .fsproj, Directory.Build.props,
Directory.Build.targets, Directory.Packages.props, other
.props/.targets and packages.config — parses every
file with line numbers, runs a deterministic subset of the anti-pattern catalogue over them
(unquoted conditions, hardcoded paths, $(TargetFramework) conditions in
.props, publish-on-build forks, defaults in .targets, analyzers without
PrivateAssets, NETSDK1022 duplicates, ...) and, for a legacy project, lists what an
SDK-style rewrite must keep. Its two paid steps are available over HTTP. You send one JSON object
with task set to "audit" or "modernize", the files as a
files string and the page’s check as a facts string, and the model
replies with one JSON object: an audit with a BAD-to-GOOD fix per finding and an answer for every
flag, or an SDK-style rewrite of the legacy project with what was removed, what was kept and what
to do by hand.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. The paths below (/guest,
/me, /estimate, /run, /jobs/{job_id},
/run-stream) are relative to it, exactly as sdk.js calls them. A
plain-text summary of the app for agents is at /llms.txt.
The response envelope
Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s
SDK (sdk.js) decides success from the HTTP status, not from ok: on a
2xx it returns data; otherwise it raises an error whose message is
error.message (or the HTTP status text when the body carries none), with
status, code (error.code) and details
(error.details) attached. A body that is not JSON is treated as {}.
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Error codes
Branch on the HTTP status and error.code, show error.message. The page
handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The body is not a JSON object or a declared field has the wrong type. task, files and facts are all strings; sending files as an array or facts as an object is the usual cause. |
401 | UNAUTHORIZED | No token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token. |
402 | INSUFFICIENT_CREDITS | The balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry). |
429 | RATE_LIMITED | Too many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key. |
Failures that do not arrive as an HTTP status:
| What | What happens |
|---|---|
event: error on /run-stream | The SDK raises an error carrying the event’s message, code and job_id. |
Job status: "failed" | Polling stops; the job’s error field says why. |
| Polling past 180 s | The SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled. |
The task field: two lanes
task routes the run, and it takes exactly two values. Each lane has its own reply
contract, and a reply never mixes them. The skill’s instructions tell the model to do the
audit when task is missing or anything else, so a typo silently gets you an audit:
always send one of the two values.
task | What it does | verdict | Use it when |
|---|---|---|---|
"audit" | Reviews every file against the anti-pattern catalogue: confirms or dismisses each page flag, adds what text rules cannot see, and gives the verbatim BAD XML and the GOOD replacement for each finding, with an ordered plan. | broken | needs_work | clean | The projects are already SDK-style, or you want the defects in a legacy project listed before you convert it. |
"modernize" | Rewrites the legacy (non-SDK) project to SDK-style: packages.config to PackageReference at the same versions, the explicit Compile lists and implicit framework references dropped, BeforeBuild/AfterBuild renamed, everything in the inventory kept. | converted | already_sdk | cannot_convert | A pasted project is legacy (flag LG-01). already_sdk means nothing was legacy; cannot_convert is the answer for an ASP.NET System.Web application (LG-07). |
The page chains the two: an audit that sets "modernize": true offers the modernize
lane on the same paste, and a converted rewrite can be sent back through the audit lane (the page
swaps the rewritten files into the paste and drops the deleted ones). Over the API that is simply
a second run with the other task.
Input fields
This is exactly what the page sends (msbkit.js buildInput,
app.js run), and what input-schema.json declares. Every
value is a string:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "audit" or "modernize". |
files | string | yes | The files, as an array of {"path", "text"} JSON-encoded into a string: JSON.stringify(files) in JavaScript, json.dumps(files) in Python, and so on. Sending the array itself is a 400. The model is told to parse it. |
facts | string | yes | The page’s free check (below), also JSON-encoded into a string. |
note | string | no | What the developer says: the symptom, the goal, a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against the files, says so when they disagree, and answers it in summary. In the modernize lane it is also the only thing that may change the target framework (and then retarget_reason says why). |
retry_note | string | no | Sent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with a note saying the previous reply was not the single valid JSON object required, for that lane. A normal call never sends it. |
Before every estimate and run the page passes the body through mustBeObject, which
refuses anything that is not a plain object of scalar fields. That is why files and
facts travel as strings.
The files string
files decodes to an array of {"path": "...", "text": "..."}, one per
pasted file, text verbatim. The page splits a paste on
<!-- file: path --> markers and sends only the files that have an XML root
element. A direct caller can build this array itself from the files on disk: use the path you
want the reply to name (every file and path in the reply must be one of
them, written exactly as sent), and send the whole file unmodified, because every
line in an audit reply is counted in that text. The page reads up to 400 000
characters and 40 files, and sends at most 60 000 characters of file text: past that it cuts the
middle of the largest files on whole lines and leaves an
<!-- [csproj-modernizer: lines X-Y (N lines) not sent] --> comment in their place
(the model is told never to quote or rewrite what was not sent), with a sentence in
facts.notes.
The facts string
facts is produced by the page’s free check: Msbkit.analyze parses
the paste and runs the rules, and buildFacts turns that analysis into this object.
It is how the model knows which flags to answer and, for a legacy project, what the rewrite must
keep. The simplest source is the page itself: paste the files, run a lane once, and the
Download .json export carries the exact object in its facts field
(the two worked examples below also show it in full). Its top-level keys, in the order built:
| Key | Contents |
|---|---|
source | files[]: per file its path, kind (project | props | targets | cpm | packages | config | other), lang, style (sdk | legacy | import | unknown), sdk, target_frameworks, lines and up to five parse_errors; then characters_read, characters_not_read and files_not_read. |
flags | Every flag the page raised: uid (F1, F2, ... most severe first), id (the catalogue id: AP-01..AP-23, MX-01..MX-06, LG-01..LG-07, XML-01), sev (high | medium | low | info), file, line, text and detail. The audit must answer every non-info flag by its uid. |
counts | How many flags of each sev. |
legacy_inventory | One entry per legacy project: path, lang, tfm (already mapped from TargetFrameworkVersion, e.g. v4.8 → net48) and tfm_from, output_type, assembly_name, root_namespace, file_stem, packages (id, version, where found), project_refs, references (non-implicit), special_items (with CopyToOutputDirectory, Link and similar metadata), keep_properties, custom_targets, renamed_targets, assembly_info, compile_count, fs_compile_order, web/wpf/winforms and define_extra. Empty when nothing is legacy. |
verdict_candidates | {"audit": ..., "modernize": ...}: the verdict each lane’s flags point at (the page’s own reading). |
notes | Plain sentences about what was not read or not sent. |
Without the page’s check. facts is required, but nothing
checks its contents: a minimal string such as "{}" or
"{\"flags\":[],\"legacy_inventory\":[]}" is accepted, and the model still reads the
files. You lose what the check adds: with no flags there is nothing to confirm or
dismiss (flag_answers comes back empty and every finding cites []), with
no legacy_inventory the modernize lane has no list of packages, references, items,
properties and targets it is held to, and with no verdict_candidates there is no
second opinion on the verdict. Build it with the page’s own msbkit.js (it runs
in Node: K.buildInput(task, K.analyze(pasteText), note) returns the whole body) or
copy it from the page.
The output contracts
The model is instructed (SKILL.md) to reply with one JSON object and
nothing else — no prose, no code fences — with every array of its lane
present ([] when empty). The run’s output text is that object.
task: "audit"
task:"audit".verdict:broken(at least one finding has severityerror) |needs_work(only warnings or style) |clean(no finding;findingsis empty).headline: one sentence, the most important thing wrong (or that nothing is).findings[]: one per problem instance that needs its own edit, each{id, flags[], file, line, severity, title, evidence, why, bad, good}.idis a catalogue id orother;flagslists the pageuids it confirms ([]for something the page did not flag);fileis a sent path;lineis the 1-based line whereevidencestarts;severityiserror|warning|style, following the catalogue band;evidenceandbadare copied verbatim from the file;goodis the minimal replacement XML (an XML comment when the fix is to delete, or one naming the file when the fix moves elsewhere).flag_answers[]:{flag, status, reason}, exactly one for every page flag whosesevishigh,mediumorlow, withstatusconfirmed(a finding lists that uid) |dismissed(with a reason tied to these files).plan[]: two to six ordered steps, errors first.modernize:truewhen a pasted project is legacy and converting it is the better first step.questions[]: things only the developer can answer.summary: two to four sentences, answeringnoteif one was sent.
task: "modernize"
task:"modernize".verdict:converted|already_sdk(no pasted project is legacy:filesempty,projectnull) |cannot_convert(an ASP.NET System.Web application or another type with no SDK-style equivalent: the options go inmanual_steps).headline: one sentence.project: the path of the legacy project the reply is about;nullforalready_sdk.target_framework: the TFM of the rewrite, equal to the inventory’stfmunless the note asked for another;retarget_reasonsays why it changed (otherwise"").files[]:{path, action, content}withactionrewrite(the project, same path, in full) |create|delete(e.g.packages.config);contentis the full file text.removed[]andkept[]:{what, why}, naming concrete elements (<Reference Include="System.Core" />, “the Debug|AnyCPU property group”).manual_steps[]: what the developer must do outside the returned files, in order.risks[]: behaviour that may change and how to check it.questions[]andsummary, as in the audit.
How the page reads a reply (recon.js normalize): it strips a code fence
and parses from the first { to the last }. A reply is read as the lane
that was asked for, unless it names the other lane and carries that lane’s body
(findings for audit, files for modernize). An unknown
verdict shows as needs_work or converted and counts as a
disagreement; an unknown severity becomes warning, an unknown
action rewrite; ids like AP02 become AP-02;
flags keeps only F<n> values; findings with no file, evidence and
title and files with no path are dropped; questions, plan,
manual_steps and risks are capped at 5, 10, 12 and 10.
Worked examples
Both are built with the app’s own modules from the examples the page ships (the example
buttons on the app page), exactly as the page builds them:
K.buildInput(e.lane, K.analyze(e.raw), e.note). Each shows the decoded
files array and facts object (save them under the file names given to
run the samples in steps 3–5), the request body, and a saved model reply (reply set
r2).
Audit: Two projects and a Directory.Build pair that race and override each other
4 files: Directory.Build.props, Directory.Build.targets, src/Orders.Core/Orders.Core.csproj, src/Orders.Api/Orders.Api.csproj. The page’s free check raises 13 flags (6 high, 4 medium, 3 low) and its own reading of the audit verdict is broken. The developer’s note: “CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?”
The files array, decoded — save it as audit-files.json:
[
{
"path": "Directory.Build.props",
"text": "<Project>\n <PropertyGroup>\n <Nullable>enable</Nullable>\n <ImplicitUsings>enable</ImplicitUsings>\n <Company>Quorlex</Company>\n </PropertyGroup>\n\n <PropertyGroup Condition=\"'$(TargetFramework)' == 'net8.0'\">\n <GenerateDocumentationFile>true</GenerateDocumentationFile>\n </PropertyGroup>\n\n <ItemGroup>\n <PackageReference Include=\"StyleCop.Analyzers\" Version=\"1.2.0-beta.556\" />\n </ItemGroup>\n</Project>"
},
{
"path": "Directory.Build.targets",
"text": "<Project>\n <PropertyGroup>\n <TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n <Authors>Quorlex Build Team</Authors>\n </PropertyGroup>\n</Project>"
},
{
"path": "src/Orders.Core/Orders.Core.csproj",
"text": "<Project Sdk=\"Microsoft.NET.Sdk\">\n <PropertyGroup>\n <TargetFramework>net8.0</TargetFramework>\n <OutputPath>C:\\builds\\output\\Orders.Core</OutputPath>\n </PropertyGroup>\n\n <PropertyGroup Condition=\"$(Configuration) == Debug\">\n <DefineConstants>ENABLE_TRACING</DefineConstants>\n </PropertyGroup>\n\n <ItemGroup>\n <Compile Include=\"OrderMath.cs\" />\n <Compile Update=\"OrderMath.cs\" AutoGen=\"true\" />\n </ItemGroup>\n\n <Target Name=\"PublishOnBuild\" AfterTargets=\"Build\">\n <MSBuild Projects=\"$(MSBuildProjectFullPath)\" Targets=\"Publish\" Properties=\"_IsPublishing=true\" />\n </Target>\n</Project>"
},
{
"path": "src/Orders.Api/Orders.Api.csproj",
"text": "<Project Sdk=\"Microsoft.NET.Sdk.Web\">\n <PropertyGroup>\n <TargetFramework>net8.0</TargetFramework>\n <TreatWarningsAsErrors>false</TreatWarningsAsErrors>\n </PropertyGroup>\n\n <ItemGroup>\n <ProjectReference Include=\"..\\Orders.Core\\Orders.Core.csproj\" SetTargetFramework=\"TargetFramework=net8.0\" />\n </ItemGroup>\n\n <Target Name=\"StampVersion\" BeforeTargets=\"Build\">\n <Exec Command=\"echo $(Version) > $(IntermediateOutputPath)version.txt\" />\n </Target>\n</Project>"
}
]
The facts object, decoded and complete — save it as audit-facts.json:
{
"source": {
"files": [
{
"path": "Directory.Build.props",
"kind": "props",
"lang": null,
"style": "import",
"sdk": null,
"target_frameworks": [],
"lines": 15,
"parse_errors": []
},
{
"path": "Directory.Build.targets",
"kind": "targets",
"lang": null,
"style": "import",
"sdk": null,
"target_frameworks": [],
"lines": 6,
"parse_errors": []
},
{
"path": "src/Orders.Core/Orders.Core.csproj",
"kind": "project",
"lang": "C#",
"style": "sdk",
"sdk": "Microsoft.NET.Sdk",
"target_frameworks": [
"net8.0"
],
"lines": 19,
"parse_errors": []
},
{
"path": "src/Orders.Api/Orders.Api.csproj",
"kind": "project",
"lang": "C#",
"style": "sdk",
"sdk": "Microsoft.NET.Sdk.Web",
"target_frameworks": [
"net8.0"
],
"lines": 14,
"parse_errors": []
}
],
"characters_read": 1841,
"characters_not_read": 0,
"files_not_read": 0
},
"flags": [
{
"uid": "F1",
"id": "AP-21",
"sev": "high",
"file": "Directory.Build.props",
"line": 9,
"text": "GenerateDocumentationFile is conditioned on $(TargetFramework) in a .props file",
"detail": "$(TargetFramework) is still empty when a .props file is evaluated for a single-targeting project, so the condition never matches. Move it to Directory.Build.targets or the project file. Item conditions are fine."
},
{
"uid": "F2",
"id": "AP-03",
"sev": "high",
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 4,
"text": "Hardcoded absolute path in OutputPath: C:\\builds\\output\\Orders.Core",
"detail": "Breaks on every other machine, CI agent and OS. Build it from $(MSBuildThisFileDirectory), $(MSBuildProjectDirectory) or a repo-root property."
},
{
"uid": "F3",
"id": "AP-02",
"sev": "high",
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 7,
"text": "Unquoted comparison in a Condition on <PropertyGroup>: $(Configuration) == Debug",
"detail": "Quote both sides with single quotes, e.g. '$(Configuration)' == 'Release'. An empty or spaced value makes the unquoted form misread or fail to parse."
},
{
"uid": "F4",
"id": "AP-05",
"sev": "high",
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 12,
"text": "1 source file listed with <Compile Include> (OrderMath.cs)",
"detail": "The SDK already globs **/*.cs, so these are duplicates: the build fails with NETSDK1022 (duplicate Compile items) unless default compile items are turned off. Delete them; use Compile Remove to opt a file out."
},
{
"uid": "F5",
"id": "AP-22",
"sev": "high",
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 17,
"text": "<MSBuild Projects=\"$(MSBuildProjectFullPath)\"> passes _IsPublishing=true",
"detail": "A global property that does not change the output path forks a second instance of that project which writes the same bin/obj as the build the solution already runs: a double build and file-lock races. Run the target in the same instance (DependsOnTargets) or give the fork its own output path."
},
{
"uid": "F6",
"id": "AP-23",
"sev": "high",
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 8,
"text": "SetTargetFramework=\"TargetFramework=net8.0\" on the reference to Orders.Core.csproj, which already single-targets net8.0",
"detail": "Path-neutral: the forked instance writes the same bin/obj as the one the solution builds. Remove the metadata."
},
{
"uid": "F7",
"id": "AP-07",
"sev": "medium",
"file": "Directory.Build.props",
"line": 13,
"text": "StyleCop.Analyzers has no PrivateAssets=\"all\"",
"detail": "without it the analyzer or build tool flows to every project and package that consumes this one."
},
{
"uid": "F8",
"id": "MX-02",
"sev": "medium",
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 8,
"text": "DefineConstants is set to 'ENABLE_TRACING' without $(DefineConstants)",
"detail": "This replaces the constants already defined for the configuration (DEBUG and TRACE, and anything an earlier group added). Append instead: $(DefineConstants);ENABLE_TRACING."
},
{
"uid": "F9",
"id": "AP-12",
"sev": "medium",
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 4,
"text": "Orders.Api.csproj sets TreatWarningsAsErrors to 'false', but Directory.Build.targets (line 3) sets it to 'true' after the project body",
"detail": "The project's value never takes effect: the .targets file is imported later and wins. Move the default to a .props file with a condition."
},
{
"uid": "F10",
"id": "AP-11",
"sev": "medium",
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 11,
"text": "Target StampVersion runs on every build (BeforeTargets=Build) without Inputs and Outputs",
"detail": "With both attributes MSBuild skips it when nothing changed; register what it writes in FileWrites so Clean removes it."
},
{
"uid": "F11",
"id": "AP-12",
"sev": "low",
"file": "Directory.Build.targets",
"line": 3,
"text": "2 properties given an unconditional value in a .targets file (TreatWarningsAsErrors, Authors)",
"detail": ".targets files are imported after the project body, so these silently override what a project sets. Defaults belong in a .props file, written as <X Condition=\"'$(X)' == ''\">."
},
{
"uid": "F12",
"id": "AP-17",
"sev": "low",
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 13,
"text": "<Compile Include> and <Compile Update> of OrderMath.cs in one ItemGroup",
"detail": "Update only sees items that exist when it is evaluated; put the Update in its own ItemGroup (or fold the metadata into the Include)."
},
{
"uid": "F13",
"id": "AP-01",
"sev": "low",
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 12,
"text": "<Exec> runs `echo $(Version) > $(IntermediateOutputPath)version.txt`",
"detail": "Use <WriteLinesToFile>: it works the same on every OS and registers the file."
}
],
"counts": {
"high": 6,
"medium": 4,
"low": 3,
"info": 0
},
"legacy_inventory": [],
"verdict_candidates": {
"audit": "broken",
"modernize": "already_sdk"
},
"notes": []
}
The request body the page sends, trimmed for display: the full body is
8,290 characters of compact JSON, and the two long strings are cut here after their first
characters, with the cut marked inside the string. Untrimmed, files is the compact
JSON.stringify of the first block and facts of the second; the
JavaScript makeInput in step 3 rebuilds the body byte for byte from the two saved
files.
{
"task": "audit",
"files": "[{\"path\":\"Directory.Build.props\",\"text\":\"<Project>\\n <PropertyGroup>\\n <Nullable>enable</Nullable>\\n <ImplicitUsings>enable</ImplicitUsings>\\n <Compan ... [trimmed for display: 160 of 1,952 characters shown]",
"facts": "{\"source\":{\"files\":[{\"path\":\"Directory.Build.props\",\"kind\":\"props\",\"lang\":null,\"style\":\"import\",\"sdk\":null,\"target_frameworks\":[],\"lines\":15,\"parse_errors\":[]}, ... [trimmed for display: 160 of 5,406 characters shown]",
"note": "CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?"
}
The page would send this with Idempotency-Key: csproj-modernizer:audit:6eb72c89-2062:a1 (see step 4). The saved
reply, in full:
{
"task": "audit",
"verdict": "broken",
"headline": "Orders.Core is built twice on an ordinary build - a self-forked Publish in its own project and a re-targeted ProjectReference from Orders.Api - which matches the intermittent Orders.Core.dll file-lock failures, on top of a hardcoded OutputPath, an unquoted condition, a duplicate Compile item that will hit NETSDK1022, and a Directory.Build.props condition on $(TargetFramework) that can never match.",
"findings": [
{
"id": "AP-21",
"flags": [
"F1"
],
"file": "Directory.Build.props",
"line": 8,
"severity": "error",
"title": "GenerateDocumentationFile conditioned on $(TargetFramework) in a .props file",
"evidence": " <PropertyGroup Condition=\"'$(TargetFramework)' == 'net8.0'\">\n <GenerateDocumentationFile>true</GenerateDocumentationFile>\n </PropertyGroup>",
"bad": " <PropertyGroup Condition=\"'$(TargetFramework)' == 'net8.0'\">\n <GenerateDocumentationFile>true</GenerateDocumentationFile>\n </PropertyGroup>",
"good": "<!-- moved to Directory.Build.targets, imported after TargetFramework is set -->\n <PropertyGroup Condition=\"'$(TargetFramework)' == 'net8.0'\">\n <GenerateDocumentationFile>true</GenerateDocumentationFile>\n </PropertyGroup>",
"why": "Directory.Build.props is imported before the project's own <TargetFramework> property is set, so $(TargetFramework) is empty when this Condition runs for both Orders.Core.csproj and Orders.Api.csproj (each single-targets net8.0): the PropertyGroup never matches and GenerateDocumentationFile is never enabled for either project. Move the condition to Directory.Build.targets (imported after the project body) or into each project file."
},
{
"id": "AP-03",
"flags": [
"F2"
],
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 4,
"severity": "error",
"title": "Hardcoded absolute OutputPath",
"evidence": " <OutputPath>C:\\builds\\output\\Orders.Core</OutputPath>",
"bad": " <OutputPath>C:\\builds\\output\\Orders.Core</OutputPath>",
"good": " <OutputPath>$(MSBuildThisFileDirectory)output\\Orders.Core\\</OutputPath>",
"why": "OutputPath is pinned to C:\\builds\\output\\Orders.Core, a path that only exists on one machine. On any other Windows machine it needs that exact folder tree, and on a Linux/macOS CI agent the literal string \"C:\\builds\\...\" isn't a drive path at all. Check what output layout CI actually expects, then build the path from $(MSBuildThisFileDirectory) or a repo-root property instead of a machine-specific absolute one."
},
{
"id": "AP-02",
"flags": [
"F3"
],
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 7,
"severity": "error",
"title": "Unquoted Configuration comparison",
"evidence": " <PropertyGroup Condition=\"$(Configuration) == Debug\">",
"bad": " <PropertyGroup Condition=\"$(Configuration) == Debug\">\n <DefineConstants>ENABLE_TRACING</DefineConstants>\n </PropertyGroup>",
"good": " <PropertyGroup Condition=\"'$(Configuration)' == 'Debug'\">\n <DefineConstants>ENABLE_TRACING</DefineConstants>\n </PropertyGroup>",
"why": "Both sides of == are bare words. Check whether $(Configuration) is ever empty or contains anything unusual for this repo's build invocations; an unquoted comparison can misread or fail to parse in those cases, silently skipping the DefineConstants below. Quote both sides so the comparison is always a literal string match."
},
{
"id": "AP-05",
"flags": [
"F4"
],
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 12,
"severity": "error",
"title": "Explicit Compile Include duplicates the SDK glob",
"evidence": " <Compile Include=\"OrderMath.cs\" />",
"bad": " <Compile Include=\"OrderMath.cs\" />",
"good": "<!-- removed: the SDK already globs OrderMath.cs via the default Compile item -->",
"why": "Microsoft.NET.Sdk already globs **/*.cs, which includes OrderMath.cs. Default compile items are not turned off anywhere in this project, so this Include adds the same file a second time and the build will fail with NETSDK1022 (duplicate 'Compile' items). Delete the Include; the file is already compiled by the default glob."
},
{
"id": "AP-22",
"flags": [
"F5"
],
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 16,
"severity": "error",
"title": "PublishOnBuild forks a second instance of this project",
"evidence": " <Target Name=\"PublishOnBuild\" AfterTargets=\"Build\">\n <MSBuild Projects=\"$(MSBuildProjectFullPath)\" Targets=\"Publish\" Properties=\"_IsPublishing=true\" />\n </Target>",
"bad": " <Target Name=\"PublishOnBuild\" AfterTargets=\"Build\">\n <MSBuild Projects=\"$(MSBuildProjectFullPath)\" Targets=\"Publish\" Properties=\"_IsPublishing=true\" />\n </Target>",
"good": " <PropertyGroup>\n <_PublishWasInvokedDirectly Condition=\"'$(_IsPublishing)' == 'true'\">true</_PublishWasInvokedDirectly>\n </PropertyGroup>\n\n <Target Name=\"PublishOnBuild\" AfterTargets=\"Build\" DependsOnTargets=\"Publish\" Condition=\"'$(_PublishWasInvokedDirectly)' != 'true'\" />",
"why": "This target runs the <MSBuild> task against its own project file after every Build, passing _IsPublishing=true - a global property that is not part of the outer build's property set. MSBuild will treat that as a second, separate instance of Orders.Core.csproj, and its Publish (which depends on Build) recompiles into the same OutputPath the outer build just used: a concrete match for the intermittent 'Orders.Core.dll ... being used by another process' failure in the note. Capture the SDK's own _IsPublishing signal and run Publish through DependsOnTargets in the same instance instead of forking one."
},
{
"id": "AP-23",
"flags": [
"F6"
],
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 8,
"severity": "error",
"title": "SetTargetFramework re-injects the TFM Orders.Core already single-targets",
"evidence": " <ProjectReference Include=\"..\\Orders.Core\\Orders.Core.csproj\" SetTargetFramework=\"TargetFramework=net8.0\" />",
"bad": " <ProjectReference Include=\"..\\Orders.Core\\Orders.Core.csproj\" SetTargetFramework=\"TargetFramework=net8.0\" />",
"good": " <ProjectReference Include=\"..\\Orders.Core\\Orders.Core.csproj\" />",
"why": "Orders.Core.csproj already single-targets net8.0. Passing SetTargetFramework=\"TargetFramework=net8.0\" still adds an explicit global property to this reference's build request, so MSBuild treats it as a project instance distinct from any other build of Orders.Core.csproj that doesn't carry that property - a path-neutral second build writing the same bin/obj as the first. Together with the PublishOnBuild self-fork in Orders.Core, this is the other concrete source of the flaky file-lock failure in the note. Remove the metadata."
},
{
"id": "AP-07",
"flags": [
"F7"
],
"file": "Directory.Build.props",
"line": 13,
"severity": "warning",
"title": "StyleCop.Analyzers has no PrivateAssets",
"evidence": " <PackageReference Include=\"StyleCop.Analyzers\" Version=\"1.2.0-beta.556\" />",
"bad": " <PackageReference Include=\"StyleCop.Analyzers\" Version=\"1.2.0-beta.556\" />",
"good": " <PackageReference Include=\"StyleCop.Analyzers\" Version=\"1.2.0-beta.556\" PrivateAssets=\"all\" />",
"why": "This PackageReference is in Directory.Build.props, so every project in the repo picks it up. Without PrivateAssets=\"all\" it also propagates to anything that references Orders.Core.csproj or Orders.Api.csproj, forcing the analyzer on consumers that never asked for it. Add PrivateAssets=\"all\"."
},
{
"id": "MX-02",
"flags": [
"F8"
],
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 8,
"severity": "warning",
"title": "DefineConstants overwritten instead of appended",
"evidence": " <DefineConstants>ENABLE_TRACING</DefineConstants>",
"bad": " <DefineConstants>ENABLE_TRACING</DefineConstants>",
"good": " <DefineConstants>$(DefineConstants);ENABLE_TRACING</DefineConstants>",
"why": "This replaces DefineConstants outright, so whenever this Debug PropertyGroup matches, the SDK's own DEBUG;TRACE symbols for the configuration are lost and only ENABLE_TRACING remains. Append with $(DefineConstants); to keep them."
},
{
"id": "AP-12",
"flags": [
"F9",
"F11"
],
"file": "Directory.Build.targets",
"line": 3,
"severity": "warning",
"title": "Property defaults set unconditionally in Directory.Build.targets",
"evidence": " <TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n <Authors>Quorlex Build Team</Authors>",
"bad": " <PropertyGroup>\n <TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n <Authors>Quorlex Build Team</Authors>\n </PropertyGroup>",
"good": "<!-- moved to Directory.Build.props -->\n<PropertyGroup>\n <TreatWarningsAsErrors Condition=\"'$(TreatWarningsAsErrors)' == ''\">true</TreatWarningsAsErrors>\n <Authors Condition=\"'$(Authors)' == ''\">Quorlex Build Team</Authors>\n</PropertyGroup>",
"why": "Directory.Build.targets is imported after the project body, so both properties always win over anything a project sets earlier. Concretely, Orders.Api.csproj sets TreatWarningsAsErrors to false (its line 4), but this line makes it true again on every build - the second thing the note asks about. Raised from the catalogue's baseline style banding because a pasted project's explicit value is demonstrably being discarded here, not just hypothetically. Move both properties to Directory.Build.props guarded by '$(X)' == '' so a project's own value is respected as an override."
},
{
"id": "AP-11",
"flags": [
"F10"
],
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 11,
"severity": "warning",
"title": "StampVersion has no Inputs/Outputs",
"evidence": " <Target Name=\"StampVersion\" BeforeTargets=\"Build\">\n <Exec Command=\"echo $(Version) > $(IntermediateOutputPath)version.txt\" />\n </Target>",
"bad": " <Target Name=\"StampVersion\" BeforeTargets=\"Build\">\n <Exec Command=\"echo $(Version) > $(IntermediateOutputPath)version.txt\" />\n </Target>",
"good": " <Target Name=\"StampVersion\" BeforeTargets=\"Build\" Inputs=\"$(MSBuildProjectFullPath)\" Outputs=\"$(IntermediateOutputPath)version.txt\">\n <Exec Command=\"echo $(Version) > $(IntermediateOutputPath)version.txt\" />\n </Target>",
"why": "Without Inputs/Outputs this target's Exec reruns on every build even when $(Version) hasn't changed, and version.txt isn't registered for Clean to remove. Give it Inputs/Outputs so MSBuild can skip it incrementally and track the file."
},
{
"id": "AP-17",
"flags": [
"F12"
],
"file": "src/Orders.Core/Orders.Core.csproj",
"line": 12,
"severity": "style",
"title": "Compile Include and Update of the same item in one ItemGroup",
"evidence": " <Compile Include=\"OrderMath.cs\" />\n <Compile Update=\"OrderMath.cs\" AutoGen=\"true\" />",
"bad": " <ItemGroup>\n <Compile Include=\"OrderMath.cs\" />\n <Compile Update=\"OrderMath.cs\" AutoGen=\"true\" />\n </ItemGroup>",
"good": " <ItemGroup>\n <Compile Include=\"OrderMath.cs\" />\n </ItemGroup>\n <ItemGroup>\n <Compile Update=\"OrderMath.cs\" AutoGen=\"true\" />\n </ItemGroup>",
"why": "Update only reliably sees items that already exist in the same group; put the Update in its own ItemGroup so evaluation order can't matter. This becomes moot once the Include above is deleted for AP-05 - only split the groups if the Include has to stay."
},
{
"id": "AP-01",
"flags": [
"F13"
],
"file": "src/Orders.Api/Orders.Api.csproj",
"line": 12,
"severity": "style",
"title": "Exec used for a plain file write",
"evidence": " <Exec Command=\"echo $(Version) > $(IntermediateOutputPath)version.txt\" />",
"bad": " <Exec Command=\"echo $(Version) > $(IntermediateOutputPath)version.txt\" />",
"good": " <WriteLinesToFile File=\"$(IntermediateOutputPath)version.txt\" Lines=\"$(Version)\" Overwrite=\"true\" />",
"why": "echo redirected with > depends on which shell MSBuild invokes and isn't tracked by MSBuild at all. WriteLinesToFile does the same job as a built-in task, registers the file, and behaves the same on every OS."
}
],
"flag_answers": [
{
"flag": "F1",
"status": "confirmed",
"reason": "Directory.Build.props loads before TargetFramework is set on either single-targeting project, so the condition never matches; see the AP-21 finding."
},
{
"flag": "F2",
"status": "confirmed",
"reason": "OutputPath is a Windows-only absolute path; see the AP-03 finding."
},
{
"flag": "F3",
"status": "confirmed",
"reason": "Both sides of == are unquoted in this Condition; see the AP-02 finding."
},
{
"flag": "F4",
"status": "confirmed",
"reason": "The Include duplicates the SDK's default Compile glob and will hit NETSDK1022; see the AP-05 finding."
},
{
"flag": "F5",
"status": "confirmed",
"reason": "The self MSBuild call forks a second instance of Orders.Core.csproj; see the AP-22 finding."
},
{
"flag": "F6",
"status": "confirmed",
"reason": "SetTargetFramework re-asserts the TFM Orders.Core already single-targets, forking a second build instance; see the AP-23 finding."
},
{
"flag": "F7",
"status": "confirmed",
"reason": "No PrivateAssets on the analyzer package, which is shared repo-wide via Directory.Build.props; see the AP-07 finding."
},
{
"flag": "F8",
"status": "confirmed",
"reason": "DefineConstants is a plain assignment, not an append; see the MX-02 finding."
},
{
"flag": "F9",
"status": "confirmed",
"reason": "Directory.Build.targets sets TreatWarningsAsErrors unconditionally after the project body, discarding Orders.Api's own false; see the AP-12 finding."
},
{
"flag": "F10",
"status": "confirmed",
"reason": "StampVersion has BeforeTargets but no Inputs/Outputs; see the AP-11 finding."
},
{
"flag": "F11",
"status": "confirmed",
"reason": "Both TreatWarningsAsErrors and Authors are set unconditionally in the .targets file; folded into the same AP-12 finding as F9."
},
{
"flag": "F12",
"status": "confirmed",
"reason": "Compile Include and Update of OrderMath.cs share one ItemGroup; see the AP-17 finding."
},
{
"flag": "F13",
"status": "confirmed",
"reason": "The Exec runs echo with redirection instead of a built-in task; see the AP-01 finding."
}
],
"plan": [
"Stop the double build of Orders.Core: remove SetTargetFramework=\"TargetFramework=net8.0\" from Orders.Api's ProjectReference (AP-23), and replace Orders.Core's self-invoking PublishOnBuild <MSBuild> call with a same-instance DependsOnTargets=\"Publish\" guarded by _PublishWasInvokedDirectly (AP-22) - this is what races on Orders.Core.dll.",
"In Orders.Core.csproj: quote both sides of the Debug condition ('$(Configuration)' == 'Debug', AP-02), delete the duplicate <Compile Include=\"OrderMath.cs\" /> (AP-05, which also removes the AP-17 Include/Update overlap), and change DefineConstants to append ($(DefineConstants);ENABLE_TRACING, MX-02).",
"Replace Orders.Core.csproj's hardcoded OutputPath (C:\\builds\\output\\Orders.Core) with a path built from $(MSBuildThisFileDirectory) (AP-03).",
"Move GenerateDocumentationFile's $(TargetFramework) condition out of Directory.Build.props into Directory.Build.targets or the project files (AP-21).",
"Move TreatWarningsAsErrors and Authors out of Directory.Build.targets into Directory.Build.props with '$(X)' == '' guards so Orders.Api's own false is respected (AP-12) - this answers the note's second question.",
"Add PrivateAssets=\"all\" to StyleCop.Analyzers (AP-07), give StampVersion Inputs/Outputs (AP-11), and swap its Exec echo for WriteLinesToFile (AP-01)."
],
"modernize": false,
"questions": [],
"summary": "The flaky 'Orders.Core.dll ... being used by another process' failure has two concrete sources: Orders.Core's PublishOnBuild target re-invokes itself via <MSBuild> with _IsPublishing=true after every Build (AP-22), and Orders.Api's ProjectReference reasserts SetTargetFramework=net8.0 on a project that already single-targets net8.0 (AP-23) - both fork a second instance of Orders.Core.csproj that writes the same bin/obj the first build is still using, which only shows up some of the time. TreatWarningsAsErrors=false in Orders.Api.csproj is overridden because Directory.Build.targets sets it unconditionally to true and .targets files import after the project body, so the project's own value never wins (AP-12); the same import-order issue silences GenerateDocumentationFile's $(TargetFramework) condition in Directory.Build.props, which loads before TargetFramework is even set (AP-21). Orders.Core.csproj also has a hardcoded Windows-only OutputPath, an unquoted Debug condition, and an explicit Compile Include that duplicates the SDK's glob and will fail with NETSDK1022. Fix the two forks first, then the import-order and file-list issues."
}
Held to the page’s own reconciliation (recon.js, run by check.js on this exact body and reply), this reply has 0 disagreements in 88 statements checked.
What the step 4 samples print for this reply (after the truncated/charged line; the Python form):
broken - Orders.Core is built twice on an ordinary build - a self-forked Publish in its own project and a re-targeted ProjectReference from Orders.Api - which matches the intermittent Orders.Core.dll file-lock failures, on top of a hardcoded OutputPath, an unquoted condition, a duplicate Compile item that will hit NETSDK1022, and a Directory.Build.props condition on $(TargetFramework) that can never match.
AP-21 Directory.Build.props 8 error
AP-03 src/Orders.Core/Orders.Core.csproj 4 error
AP-02 src/Orders.Core/Orders.Core.csproj 7 error
AP-05 src/Orders.Core/Orders.Core.csproj 12 error
AP-22 src/Orders.Core/Orders.Core.csproj 16 error
AP-23 src/Orders.Api/Orders.Api.csproj 8 error
AP-07 Directory.Build.props 13 warning
MX-02 src/Orders.Core/Orders.Core.csproj 8 warning
AP-12 Directory.Build.targets 3 warning
AP-11 src/Orders.Api/Orders.Api.csproj 11 warning
AP-17 src/Orders.Core/Orders.Core.csproj 12 style
AP-01 src/Orders.Api/Orders.Api.csproj 12 style
Modernize: A .NET Framework 4.8 console importer with packages.config
2 files: src/Quorlex.Importer/Quorlex.Importer.csproj, src/Quorlex.Importer/packages.config. src/Quorlex.Importer/Quorlex.Importer.csproj is a legacy project (TargetFrameworkVersion v4.8, mapped to net48) with 2 packages in its inventory. The page’s check raises 8 flags and reads the modernize verdict as converted. The developer’s note: “Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.”
The files array, decoded — save it as modernize-files.json:
[
{
"path": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"text": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<Project ToolsVersion=\"15.0\" DefaultTargets=\"Build\" xmlns=\"http://schemas.microsoft.com/developer/msbuild/2003\">\n <Import Project=\"$(MSBuildExtensionsPath)\\$(MSBuildToolsVersion)\\Microsoft.Common.props\" Condition=\"Exists('$(MSBuildExtensionsPath)\\$(MSBuildToolsVersion)\\Microsoft.Common.props')\" />\n <PropertyGroup>\n <Configuration Condition=\" '$(Configuration)' == '' \">Debug</Configuration>\n <Platform Condition=\" '$(Platform)' == '' \">AnyCPU</Platform>\n <ProjectGuid>{7B3E6A41-2C5D-4F0E-9A8B-3D1C0E5F4A21}</ProjectGuid>\n <OutputType>Exe</OutputType>\n <RootNamespace>Quorlex.Importer</RootNamespace>\n <AssemblyName>quorlex-import</AssemblyName>\n <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>\n <FileAlignment>512</FileAlignment>\n <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>\n <Deterministic>true</Deterministic>\n <LangVersion>7.3</LangVersion>\n <TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n </PropertyGroup>\n <PropertyGroup Condition=\" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' \">\n <PlatformTarget>AnyCPU</PlatformTarget>\n <DebugSymbols>true</DebugSymbols>\n <DebugType>full</DebugType>\n <Optimize>false</Optimize>\n <OutputPath>bin\\Debug\\</OutputPath>\n <DefineConstants>DEBUG;TRACE</DefineConstants>\n <ErrorReport>prompt</ErrorReport>\n <WarningLevel>4</WarningLevel>\n <Prefer32Bit>false</Prefer32Bit>\n </PropertyGroup>\n <PropertyGroup Condition=\" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' \">\n <PlatformTarget>AnyCPU</PlatformTarget>\n <DebugType>pdbonly</DebugType>\n <Optimize>true</Optimize>\n <OutputPath>bin\\Release\\</OutputPath>\n <DefineConstants>TRACE;IMPORTER_RELEASE</DefineConstants>\n <ErrorReport>prompt</ErrorReport>\n <WarningLevel>4</WarningLevel>\n <Prefer32Bit>false</Prefer32Bit>\n </PropertyGroup>\n <ItemGroup>\n <Reference Include=\"CsvHelper, Version=30.0.0.0, Culture=neutral, PublicKeyToken=8c4959082be5c823, processorArchitecture=MSIL\">\n <HintPath>..\\..\\packages\\CsvHelper.30.0.1\\lib\\net47\\CsvHelper.dll</HintPath>\n </Reference>\n <Reference Include=\"Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL\">\n <HintPath>..\\..\\packages\\Newtonsoft.Json.13.0.3\\lib\\net45\\Newtonsoft.Json.dll</HintPath>\n </Reference>\n <Reference Include=\"System\" />\n <Reference Include=\"System.Configuration\" />\n <Reference Include=\"System.Core\" />\n <Reference Include=\"System.Xml.Linq\" />\n <Reference Include=\"System.Data.DataSetExtensions\" />\n <Reference Include=\"Microsoft.CSharp\" />\n <Reference Include=\"System.Data\" />\n <Reference Include=\"System.Net.Http\" />\n <Reference Include=\"System.Xml\" />\n </ItemGroup>\n <ItemGroup>\n <Compile Include=\"ImportJob.cs\" />\n <Compile Include=\"Mapping\\CustomerMap.cs\" />\n <Compile Include=\"Program.cs\" />\n <Compile Include=\"Properties\\AssemblyInfo.cs\" />\n </ItemGroup>\n <ItemGroup>\n <None Include=\"App.config\" />\n <None Include=\"packages.config\" />\n <Content Include=\"mappings\\customers.json\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </Content>\n </ItemGroup>\n <ItemGroup>\n <ProjectReference Include=\"..\\Quorlex.Data\\Quorlex.Data.csproj\">\n <Project>{2F1B8C0D-6E4A-4B9F-8C21-5A7D3E9B0C14}</Project>\n <Name>Quorlex.Data</Name>\n </ProjectReference>\n </ItemGroup>\n <Import Project=\"$(MSBuildToolsPath)\\Microsoft.CSharp.targets\" />\n <Target Name=\"AfterBuild\">\n <Copy SourceFiles=\"$(ProjectDir)run-import.cmd\" DestinationFolder=\"$(OutDir)\" />\n </Target>\n</Project>"
},
{
"path": "src/Quorlex.Importer/packages.config",
"text": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<packages>\n <package id=\"CsvHelper\" version=\"30.0.1\" targetFramework=\"net48\" />\n <package id=\"Newtonsoft.Json\" version=\"13.0.3\" targetFramework=\"net48\" />\n</packages>"
}
]
The facts object, decoded and complete — save it as modernize-facts.json:
{
"source": {
"files": [
{
"path": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"kind": "project",
"lang": "C#",
"style": "legacy",
"sdk": null,
"target_frameworks": [
"net48"
],
"lines": 79,
"parse_errors": []
},
{
"path": "src/Quorlex.Importer/packages.config",
"kind": "packages",
"lang": null,
"style": "none",
"sdk": null,
"target_frameworks": [],
"lines": 5,
"parse_errors": []
}
],
"characters_read": 3983,
"characters_not_read": 0,
"files_not_read": 0
},
"flags": [
{
"uid": "F1",
"id": "AP-06",
"sev": "medium",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 40,
"text": "<Reference Include=\"CsvHelper\"> points into the packages folder",
"detail": "The packages.config pattern: no transitive dependencies, no version resolution, a restore that must be run separately. Replace with <PackageReference Include=\"CsvHelper\" Version=\"30.0.1\" />."
},
{
"uid": "F2",
"id": "AP-06",
"sev": "medium",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 43,
"text": "<Reference Include=\"Newtonsoft.Json\"> points into the packages folder",
"detail": "The packages.config pattern: no transitive dependencies, no version resolution, a restore that must be run separately. Replace with <PackageReference Include=\"Newtonsoft.Json\" Version=\"13.0.3\" />."
},
{
"uid": "F3",
"id": "LG-01",
"sev": "info",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 2,
"text": "Quorlex.Importer.csproj is a legacy (non-SDK) project (ToolsVersion 15.0)",
"detail": "The modernize lane rewrites it to SDK-style; the audit catalogue applies to it only partly."
},
{
"uid": "F4",
"id": "LG-06",
"sev": "info",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 19,
"text": "Per-configuration property groups (17 properties)",
"detail": "The SDK's Debug/Release defaults match the standard ones; only the non-standard values survive."
},
{
"uid": "F5",
"id": "LG-04",
"sev": "info",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 40,
"text": "Packages come from packages.config / HintPath references",
"detail": "They become <PackageReference> items with the same versions; binding redirects are generated."
},
{
"uid": "F6",
"id": "LG-05",
"sev": "info",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 46,
"text": "8 framework references the SDK adds implicitly (System, System.Core, System.Xml.Linq, System.Data.DataSetExtensions)",
"detail": "Delete them after migration."
},
{
"uid": "F7",
"id": "LG-02",
"sev": "info",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 57,
"text": "4 Compile items listed by hand",
"detail": "SDK-style projects glob them; only files outside the folder or with special metadata stay."
},
{
"uid": "F8",
"id": "LG-03",
"sev": "info",
"file": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"line": 60,
"text": "Properties/AssemblyInfo is compiled",
"detail": "The SDK generates these attributes; keep the file and GenerateAssemblyInfo=false, or move the values into the project and delete it (otherwise CS0579 duplicate attributes)."
}
],
"counts": {
"high": 0,
"medium": 2,
"low": 0,
"info": 6
},
"legacy_inventory": [
{
"path": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"lang": "C#",
"style": "legacy",
"tfm_from": "v4.8",
"tfm": "net48",
"output_type": "Exe",
"assembly_name": "quorlex-import",
"root_namespace": "Quorlex.Importer",
"file_stem": "Quorlex.Importer",
"packages": [
{
"id": "CsvHelper",
"version": "30.0.1",
"from": "packages.config"
},
{
"id": "Newtonsoft.Json",
"version": "13.0.3",
"from": "packages.config"
}
],
"project_refs": [
"..\\Quorlex.Data\\Quorlex.Data.csproj"
],
"references": [
{
"include": "System.Configuration",
"hint_path": null
}
],
"special_items": [
{
"type": "Content",
"include": "mappings\\customers.json",
"metadata": {
"CopyToOutputDirectory": "PreserveNewest"
}
}
],
"keep_properties": [
{
"name": "OutputType",
"value": "Exe",
"condition": "",
"line": 8
},
{
"name": "AssemblyName",
"value": "quorlex-import",
"condition": "",
"line": 10
},
{
"name": "LangVersion",
"value": "7.3",
"condition": "",
"line": 15
},
{
"name": "TreatWarningsAsErrors",
"value": "true",
"condition": "",
"line": 16
},
{
"name": "Prefer32Bit",
"value": "false",
"condition": " '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ",
"line": 27
}
],
"custom_targets": [],
"renamed_targets": [
"AfterBuild"
],
"assembly_info": true,
"compile_count": 4,
"fs_compile_order": [],
"web": false,
"wpf": false,
"winforms": false,
"define_extra": [
{
"name": "IMPORTER_RELEASE",
"condition": "'$(Configuration)|$(Platform)' == 'Release|AnyCPU'"
}
]
}
],
"verdict_candidates": {
"audit": "needs_work",
"modernize": "converted"
},
"notes": []
}
The request body, trimmed for display the same way (the full body is 9,501 characters):
{
"task": "modernize",
"files": "[{\"path\":\"src/Quorlex.Importer/Quorlex.Importer.csproj\",\"text\":\"<?xml version=\\\"1.0\\\" encoding=\\\"utf-8\\\"?>\\n<Project ToolsVersion=\\\"15.0\\\" DefaultTargets=\\\"Buil ... [trimmed for display: 160 of 4,185 characters shown]",
"facts": "{\"source\":{\"files\":[{\"path\":\"src/Quorlex.Importer/Quorlex.Importer.csproj\",\"kind\":\"project\",\"lang\":\"C#\",\"style\":\"legacy\",\"sdk\":null,\"target_frameworks\":[\"net48\" ... [trimmed for display: 160 of 4,336 characters shown]",
"note": "Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later."
}
The page would send this with Idempotency-Key: csproj-modernizer:modernize:e851b2f6-251d:a1. The saved reply, in full:
{
"task": "modernize",
"verdict": "converted",
"headline": "Quorlex.Importer.csproj is now SDK-style on net48, with CsvHelper and Newtonsoft.Json as PackageReference instead of packages.config.",
"project": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"target_framework": "net48",
"retarget_reason": "",
"files": [
{
"path": "src/Quorlex.Importer/Quorlex.Importer.csproj",
"action": "rewrite",
"content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n\n <PropertyGroup>\n <OutputType>Exe</OutputType>\n <TargetFramework>net48</TargetFramework>\n <AssemblyName>quorlex-import</AssemblyName>\n <LangVersion>7.3</LangVersion>\n <TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n <Prefer32Bit>false</Prefer32Bit>\n <GenerateAssemblyInfo>false</GenerateAssemblyInfo>\n </PropertyGroup>\n\n <PropertyGroup Condition=\" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' \">\n <DefineConstants>$(DefineConstants);IMPORTER_RELEASE</DefineConstants>\n </PropertyGroup>\n\n <ItemGroup>\n <Reference Include=\"System.Configuration\" />\n </ItemGroup>\n\n <ItemGroup>\n <PackageReference Include=\"CsvHelper\" Version=\"30.0.1\" />\n <PackageReference Include=\"Newtonsoft.Json\" Version=\"13.0.3\" />\n </ItemGroup>\n\n <ItemGroup>\n <ProjectReference Include=\"..\\Quorlex.Data\\Quorlex.Data.csproj\" />\n </ItemGroup>\n\n <ItemGroup>\n <None Update=\"mappings\\customers.json\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </None>\n </ItemGroup>\n\n <Target Name=\"CopyAfterBuild\" AfterTargets=\"Build\">\n <Copy SourceFiles=\"$(ProjectDir)run-import.cmd\" DestinationFolder=\"$(OutDir)\" />\n </Target>\n\n</Project>\n"
},
{
"path": "src/Quorlex.Importer/packages.config",
"action": "delete",
"content": ""
}
],
"removed": [
{
"what": "the <?xml ...?> declaration, ToolsVersion/DefaultTargets/xmlns attributes on <Project>, and the Microsoft.Common.props / Microsoft.CSharp.targets imports",
"why": "the Microsoft.NET.Sdk import brings all of this in automatically; SDK-style projects don't declare them"
},
{
"what": "<ProjectGuid>{7B3E6A41-2C5D-4F0E-9A8B-3D1C0E5F4A21}</ProjectGuid>",
"why": "not used by SDK-style tooling; the project is tracked by path, not this GUID"
},
{
"what": "<Configuration Condition=\" '$(Configuration)' == '' \">Debug</Configuration> and <Platform Condition=\" '$(Platform)' == '' \">AnyCPU</Platform>",
"why": "MSBuild/dotnet build already default Configuration to Debug and Platform to AnyCPU when neither is supplied"
},
{
"what": "<FileAlignment>512</FileAlignment>",
"why": "512 is already the compiler default"
},
{
"what": "<Deterministic>true</Deterministic>",
"why": "true is already the SDK compiler default"
},
{
"what": "<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>",
"why": "not in the inventoried keep list for this project; verify after conversion whether CsvHelper/Newtonsoft.Json still need a generated redirect in app.config (see risks)"
},
{
"what": "<RootNamespace>Quorlex.Importer</RootNamespace>",
"why": "equals the project file name (Quorlex.Importer.csproj), which is the SDK's default root namespace"
},
{
"what": "the Debug|AnyCPU and Release|AnyCPU groups' PlatformTarget, DebugSymbols, DebugType, Optimize, OutputPath, ErrorReport, WarningLevel, and Debug's DefineConstants=DEBUG;TRACE",
"why": "these match the SDK's own Debug/Release defaults for a net48 project; only the Release DefineConstants addition (IMPORTER_RELEASE) and Prefer32Bit were non-default values and are kept"
},
{
"what": "<Compile Include=\"ImportJob.cs\" />, <Compile Include=\"Mapping\\CustomerMap.cs\" />, <Compile Include=\"Program.cs\" />, <Compile Include=\"Properties\\AssemblyInfo.cs\" />",
"why": "Microsoft.NET.Sdk globs **/*.cs by default; listing them explicitly would duplicate the glob and fail with NETSDK1022 unless default items were disabled"
},
{
"what": "<Reference Include=\"System\" />, System.Core, System.Xml.Linq, System.Data.DataSetExtensions, Microsoft.CSharp, System.Data, System.Net.Http, System.Xml",
"why": "Microsoft.NET.Sdk references these implicitly for a net48 target; System.Configuration is the one reference kept because it is not part of that implicit set"
},
{
"what": "<Reference Include=\"CsvHelper, Version=30.0.0.0, ...\"> and <Reference Include=\"Newtonsoft.Json, Version=13.0.0.0, ...\"> with their packages/ HintPath",
"why": "replaced by <PackageReference> items at the same NuGet package versions (30.0.1, 13.0.3) from packages.config; NuGet restore resolves the assemblies instead of a checked-in packages/ folder"
},
{
"what": "<None Include=\"App.config\" />",
"why": "the SDK's default None glob already includes every non-Compile/EmbeddedResource file, App.config included"
},
{
"what": "<None Include=\"packages.config\" /> and the packages.config file itself",
"why": "superseded by <PackageReference> items; packages.config is deleted"
},
{
"what": "the <Project>{2F1B8C0D-6E4A-4B9F-8C21-5A7D3E9B0C14}</Project> and <Name>Quorlex.Data</Name> child elements of the Quorlex.Data ProjectReference",
"why": "legacy VS project-system metadata; SDK-style MSBuild resolves ProjectReference by path alone"
}
],
"kept": [
{
"what": "<OutputType>Exe</OutputType>",
"why": "not Library, so it is not the SDK default"
},
{
"what": "<AssemblyName>quorlex-import</AssemblyName>",
"why": "differs from the file name Quorlex.Importer, so it must stay explicit"
},
{
"what": "<LangVersion>7.3</LangVersion>",
"why": "explicit developer choice; the SDK wouldn't otherwise pin it"
},
{
"what": "<TreatWarningsAsErrors>true</TreatWarningsAsErrors>",
"why": "explicit developer choice, not an SDK default"
},
{
"what": "<Prefer32Bit>false</Prefer32Bit>",
"why": "explicit non-default value in the legacy file, set identically in both Debug and Release, so it is hoisted to the unconditioned PropertyGroup instead of repeated per-config"
},
{
"what": "Release-only <DefineConstants>$(DefineConstants);IMPORTER_RELEASE</DefineConstants>",
"why": "custom constant beyond DEBUG/TRACE; appended with $(DefineConstants) so it doesn't overwrite the SDK's own Release value (MX-02)"
},
{
"what": "<Reference Include=\"System.Configuration\" />",
"why": "not part of the SDK's implicit net48 framework reference set"
},
{
"what": "CsvHelper 30.0.1 and Newtonsoft.Json 13.0.3 as PackageReference",
"why": "same packages, same versions as packages.config, now managed by NuGet restore"
},
{
"what": "ProjectReference to ..\\Quorlex.Data\\Quorlex.Data.csproj",
"why": "project dependency, unchanged"
},
{
"what": "mappings\\customers.json as <None Update> with CopyToOutputDirectory=PreserveNewest",
"why": "the SDK's default None glob already creates this item; Update adds the same copy metadata the legacy Content item had (a Content Update here would silently match nothing, since Microsoft.NET.Sdk doesn't default Content items)"
},
{
"what": "the AfterBuild target's Copy task, renamed to CopyAfterBuild with AfterTargets=\"Build\"",
"why": "Microsoft.NET.Sdk imports its own empty AfterBuild target after the project body, so a target still named AfterBuild would never run (MX-05); the Copy step itself is unchanged"
},
{
"what": "Properties/AssemblyInfo.cs (left on disk, not deleted)",
"why": "GenerateAssemblyInfo is set to false so the SDK does not generate duplicate attributes (CS0579) alongside it"
}
],
"manual_steps": [
"Run dotnet restore / dotnet build once to confirm CsvHelper 30.0.1 and Newtonsoft.Json 13.0.3 restore via PackageReference and the project still compiles.",
"Confirm run-import.cmd still exists at the project root, since CopyAfterBuild still copies $(ProjectDir)run-import.cmd to $(OutDir).",
"Check the built output's quorlex-import.exe.config for the binding redirects AutoGenerateBindingRedirects used to add; add <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> back if any are missing.",
"Optional follow-up: move the attributes from Properties/AssemblyInfo.cs into the project (or generated properties), delete Properties/AssemblyInfo.cs, and remove GenerateAssemblyInfo=false.",
"Delete packages.config from source control (this reply already returns that as a delete) and confirm no other tooling (CI restore step, NuGet.Config) still expects it.",
"Re-run the importer once end-to-end (or its tests) to confirm CsvHelper/Newtonsoft.Json behavior and the customers.json mapping file are unaffected."
],
"risks": [
"DebugType/Optimize/OutputPath/WarningLevel are no longer set explicitly for Debug/Release; check what the SDK actually produces for a net48 target before assuming parity with the legacy full/pdbonly PDB settings, especially if a symbol pipeline depends on the exact debug-info format.",
"AutoGenerateBindingRedirects is no longer set explicitly; verify the generated quorlex-import.exe.config still carries any redirects CsvHelper/Newtonsoft.Json need (see manual_steps).",
"The default Compile glob now recurses the whole src/Quorlex.Importer folder; verify there are no stray .cs files there that weren't in the original explicit Compile list, since they will now compile too.",
"mappings\\customers.json moved from <Content Include> to <None Update>; the file is still copied to output with CopyToOutputDirectory=PreserveNewest, but if any custom target or code specifically queries MSBuild Content items (rather than reading the file from disk at runtime), update it to expect a None item instead."
],
"questions": [],
"summary": "Quorlex.Importer.csproj is rewritten as a plain Microsoft.NET.Sdk project on net48, per the note staying on .NET Framework 4.8 for now (no .NET 8 retarget attempted). CsvHelper and Newtonsoft.Json move from packages.config/HintPath references to PackageReference at the same versions (30.0.1, 13.0.3), and packages.config is deleted. The four explicit Compile items, the eight implicit framework references, and the redundant App.config/packages.config None items are dropped in favor of the SDK's default globs, while System.Configuration, the Quorlex.Data ProjectReference, the customers.json copy item, LangVersion/TreatWarningsAsErrors/Prefer32Bit/OutputType/AssemblyName, and the extra Release DefineConstants all stay. AfterBuild is renamed CopyAfterBuild (AfterTargets=\"Build\") since the SDK would otherwise silently drop the legacy target name, and GenerateAssemblyInfo is turned off so Properties/AssemblyInfo.cs keeps compiling."
}
Held to the page’s own reconciliation (recon.js, run by check.js on this exact body and reply), this reply has 0 disagreements in 27 statements checked.
What the step 5 samples print for this reply (the Python form):
converted - Quorlex.Importer.csproj is now SDK-style on net48, with CsvHelper and Newtonsoft.Json as PackageReference instead of packages.config.
target_framework: net48
rewrite src/Quorlex.Importer/Quorlex.Importer.csproj
delete src/Quorlex.Importer/packages.config
The page checks the reply against the files; the API does not
What comes back from the API is the model’s raw text in output.output. The page
holds it to account before showing it (recon.js reconcile, with the
facts and the files it sent), and a direct API caller gets none of this:
- Audit: every finding is anchored. Its
idis in the catalogue, itsfilewas sent, itslineis inside that file, itsevidenceandbadare found verbatim in it (whitespace collapsed; a line more than two off is corrected from the evidence), every citeduidis a page flag, and itsgoodparses as XML, has no unquoted condition left and, for AP-03, no absolute path. - Audit: every flag is answered once, consistently. A missing answer to a high or medium flag, a
confirmedthat no finding lists, or adismissedthat a finding confirms or that has no reason, is a disagreement. - Audit: the verdict fits. An error finding needs
broken;brokenneeds an error finding;cleanneeds no findings;modernize: trueneeds a legacy project. - Modernize: the rewrite parses and is SDK-style. Every returned file is well-formed; the project has an
Sdkattribute, noToolsVersion, 2003xmlnsor imports of the common/language targets; arewritenames a sent file. - Modernize: nothing in the inventory is lost. The target framework (or a
retarget_reason),OutputType,AssemblyName/RootNamespace, every package at the same version, every project and assembly reference, every special item with its metadata (and noUpdateof an item type the SDK does not create, such asContentoutside the Web SDK), every kept property or a reason, every extra constant, every custom target,BeforeBuild/AfterBuildcarried over under a new name withBeforeTargets/AfterTargets,GenerateAssemblyInfo=falseor a delete ofAssemblyInfo, the F# compile order, and noCompileitem the SDK already globs. - Modernize: no new anti-pattern. The page runs its own free check over the rewrite; a new high flag is a disagreement.
If you call the API directly, apply your own checks — the evidence against your files, every flag answered, the kept packages and versions — and build the result before you commit anything.
Step 1 — a tiny client helper and a token
One function per language that does what sdk.js does for every call: JSON in,
bearer token if you have one, data out on a 2xx, an error carrying
status, error.code and error.message otherwise. The later
steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside
main / the top-level statements; Java’s makeInput from step 3 goes
in the class).
Then a token. Two kinds work:
-
Personal — needed for a run. Both lanes are metered in credits, so a run
needs a signed-in user (the page says “The audit run is metered, so it needs an
account.”) unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN(the samples fall back to the placeholderYOUR_TOKEN). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "csproj-modernizer"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # see below
# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
method="$1"; path="$2"; shift 2
curl -sS -X "$method" "$BASE$path" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data' failure: jq '.error | {code, message}'
# ---- get a token
# personal token: export SKILLSAFE_TOKEN=... (from /tokens.html) and skip this
# guest token: no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"csproj-modernizer"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see below
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
# ---- get a token
# personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "csproj-modernizer"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// msbuild.mjs -- Node 18+ (global fetch), run with: node msbuild.mjs
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // see below
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
// ---- get a token
// personal token: paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "csproj-modernizer" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see below
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// the token code below and steps 2-5 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// ---- inside main: get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "csproj-modernizer"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// CsprojModernizer.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class CsprojModernizer {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // see below
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// the token code below and steps 2-5 go here (makeInput from step 3 goes next to api())
}
}
// ---- inside main: get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "csproj-modernizer"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see below
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
# ---- get a token
# personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "csproj-modernizer" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // see below
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// ---- get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "csproj-modernizer"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// Program.cs -- .NET 6+ console app with top-level statements; the token code and steps 2-5 (with the MakeInput local function) go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // see below
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
// ---- top-level statements (above the Api class): get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "csproj-modernizer" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
Step 2 — GET /me
Who the token belongs to and what it can spend. sdk.js documents the shape as
{subject_type, subject_id, credits, profile?}. The page treats the caller as signed
in only when subject_type is "user", shows credits as the
balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it
with the estimate in step 3 before it lets you run.
api GET /me | jq '.data // .error'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
Step 3 — build the input, then POST /estimate
First a small makeInput that does what buildInput does: task,
then files and facts as compact JSON strings, then the note when it is not
blank. The samples build the audit input from the two blocks of the audit worked
example; step 5 reuses makeInput for the modernize one. Then price it. The body of
/estimate is the same input you will send to /run; no job is created and
nothing is charged — /estimate is free. From the response the page reads:
| Field | Meaning |
|---|---|
hold_credits | A reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ... for the audit run”. What you actually pay is the job’s charged_credits after settlement (step 4), normally less; the rest of the hold is released. A balance below the hold is allowed but the reply may be cut short (truncated: true). |
min_credits | The smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input warnings (for example a missing required field). They do not refuse the call, so check them before you run. |
The page re-prices whenever the files, the note or the lane change. The amounts move with the
model’s rates and grow with the size of files and facts (a rewrite
also writes a whole project file back), so read them from the call rather than hard-coding them.
# audit-files.json and audit-facts.json = the two blocks of the audit worked example.
# jq -c turns each into ONE compact string; note is optional (the page trims it, max 2000 chars).
BODY=$(jq -n --arg files "$(jq -c . audit-files.json)" --arg facts "$(jq -c . audit-facts.json)" \
--arg note "CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?" \
'{task: "audit", files: $files, facts: $facts, note: $note}')
api POST /estimate -d "$BODY" \
| jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings, model, model_alias, markup_bps})'
def make_input(task, files_path, facts_path, note=None):
"""The body the page sends: task, then files and facts as JSON STRINGS, then an optional note."""
with open(files_path, encoding="utf-8") as f:
files = json.load(f) # [{"path": ..., "text": ...}]
with open(facts_path, encoding="utf-8") as f:
facts = json.load(f) # the page's free check
inp = {
"task": task,
"files": json.dumps(files, separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False), # a STRING
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
return inp
inp = make_input("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, then files and facts as JSON STRINGS, then an optional note.
function makeInput(task, filesPath, factsPath, note) {
const input = {
task,
files: JSON.stringify(JSON.parse(readFileSync(filesPath, "utf8"))), // a STRING: [{path, text}]
facts: JSON.stringify(JSON.parse(readFileSync(factsPath, "utf8"))), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
return input;
}
const input = makeInput("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, then files and facts as JSON STRINGS, then an optional note.
makeInput := func(task, filesPath, factsPath, note string) map[string]any {
compact := func(path string) string {
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
panic(err)
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // keep the XML's < > & as they are
if err := enc.Encode(v); err != nil {
panic(err)
}
return strings.TrimSpace(buf.String()) // travels as a STRING
}
in := map[string]any{"task": task, "files": compact(filesPath), "facts": compact(factsPath)}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
return in
}
input := makeInput("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the CsprojModernizer class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String filesPath, String factsPath, String note) throws Exception {
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("files", JSON.writeValueAsString(JSON.readTree(Files.readString(Path.of(filesPath))))); // a STRING: [{path, text}]
in.put("facts", JSON.writeValueAsString(JSON.readTree(Files.readString(Path.of(factsPath))))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?");
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, then files and facts as JSON STRINGS, then an optional note.
def make_input(task, files_path, facts_path, note = nil)
input = {
task: task,
files: JSON.generate(JSON.parse(File.read(files_path, encoding: "UTF-8"))), # a STRING: [{path, text}]
facts: JSON.generate(JSON.parse(File.read(facts_path, encoding: "UTF-8"))) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input
end
input = make_input("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, then files and facts as JSON STRINGS, then an optional note.
function make_input(string $task, string $filesPath, string $factsPath, string $note = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$compact = fn(string $path) => json_encode(json_decode(file_get_contents($path)), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$input = ["task" => $task, "files" => $compact($filesPath), "facts" => $compact($factsPath)]; // STRINGS
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
return $input;
}
$input = make_input("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, then files and facts as JSON STRINGS, then an optional note.
static Dictionary<string, string> MakeInput(string task, string filesPath, string factsPath, string? note = null)
{
var input = new Dictionary<string, string>
{
["task"] = task,
["files"] = JsonNode.Parse(File.ReadAllText(filesPath))!.ToJsonString(), // a STRING: [{path, text}]
["facts"] = JsonNode.Parse(File.ReadAllText(factsPath))!.ToJsonString(), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
return input;
}
var input = MakeInput("audit", "audit-files.json", "audit-facts.json",
"CI fails about one build in five with 'The process cannot access the file Orders.Core.dll because it is being used by another process', and TreatWarningsAsErrors=false in Orders.Api seems to be ignored. What is going on?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
Step 4 — POST /run, then poll the job (audit)
Send the same input to /run with an Idempotency-Key header, using a
personal token. Its data carries a job_id. Poll
GET /jobs/{job_id} the way sdk.js waitForJob does: every
second until status is succeeded or failed, giving up on
the client side after 180 seconds. On the finished job, read:
output.output— the model’s reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples run the audit input and print the verdict and headline, then each finding’sid,file,lineandseverity.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 7 sections recovered” for an audit, “N of 10” for a rewrite), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
Idempotency key. Resending a request with the same key must not start a second
paid run, so derive the key from the input, not from the clock — and include the lane, so an
audit and a rewrite of the same files never share a key. The page uses
csproj-modernizer:<lane>:<hash>:a<attempt>, where
<lane> is audit or modernize,
<hash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a
dash, and the string’s length in hex, and <attempt> is 1 for
a normal run and 2 for the automatic reformat retry (which also adds
retry_note). For the two worked examples that is csproj-modernizer:audit:6eb72c89-2062:a1 and
csproj-modernizer:modernize:e851b2f6-251d:a1. The format is the page’s convention, not a requirement: any stable,
unique string does. The samples below use the lane and a SHA-256 prefix.
The page itself uses /run-stream (step 5), not /run; both take the
same body and header.
KEY="csproj-modernizer:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"
JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')
for i in $(seq 180); do # 1 s interval, 180 s client-side limit
J=$(api GET "/jobs/$JOB")
S=$(printf '%s' "$J" | jq -r '.data.status')
if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
sleep 1
done
printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' | jq '{verdict, headline, findings: [(.findings // [])[] | {id, file, line, severity}]}'
body = json.dumps(inp)
key = "csproj-modernizer:audit:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for f in reply.get("findings", []):
print(f["id"], f["file"], f.get("line"), f["severity"])
const key = "csproj-modernizer:audit:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const f of reply.findings || []) console.log(f.id, f.file, f.line, f.severity);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "csproj-modernizer:audit:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
findings, _ := reply["findings"].([]any)
for _, x := range findings {
f, _ := x.(map[string]any)
fmt.Println(f["id"], f["file"], f["line"], f["severity"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "csproj-modernizer:audit:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " " + f.path("file").asText() + " " + f.path("line") + " " + f.path("severity").asText());
}
key = "csproj-modernizer:audit:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["findings"] || []).each { |f| puts "#{f["id"]} #{f["file"]} #{f["line"].inspect} #{f["severity"]}" }
$key = "csproj-modernizer:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " ", $f->file, " ", json_encode($f->line ?? null), " ", $f->severity, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "csproj-modernizer:audit:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} {f?["file"]} {f?["line"]?.ToJsonString() ?? "null"} {f?["severity"]}");
Step 5 — POST /run-stream (Server-Sent Events, modernize)
The alternative to step 4, and what the page uses: same kind of body, same
Idempotency-Key header, one request. Run one or the other for a given input, not
both. To show the second lane, the samples build the modernize input from the
modernize worked example (with its own key) and stream it; to stream the audit instead, pass the
step 3 input and key. The response is text/event-stream. sdk.js splits
it into frames on a blank line, takes the event name from an event: line (default
message) and concatenates the data: lines, skips frames with no data or
with data that is not JSON, and handles these events:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated exactly like done. If it carries a job_id and no output, poll that job as in step 4. |
error | {message, code, job_id} | After the stream ends, raises an error with those fields. |
If the response is not text/event-stream, it is an ordinary JSON envelope:
the SDK returns its data (an idempotent replay returns this way) or raises the
error on a non-2xx status. Handle both shapes.
From the final payload the page reads output.output (falling back to the
concatenated delta text), truncated and charged_credits.
Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the
final payload; if the stream fails part-way, it tries to recover a partial reply from whatever
delta text arrived. The samples print the verdict and headline, the
target_framework, and each returned file’s action and
path; the rewritten project itself is that file’s content.
# the modernize worked example: modernize-files.json and modernize-facts.json
MBODY=$(jq -n --arg files "$(jq -c . modernize-files.json)" --arg facts "$(jq -c . modernize-facts.json)" \
--arg note "Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later." \
'{task: "modernize", files: $files, facts: $facts, note: $note}')
MKEY="csproj-modernizer:modernize:$(printf '%s' "$MBODY" | shasum -a 256 | cut -c1-16):a1"
# -N turns off buffering so frames print as they arrive
api POST /run-stream -N -H "Idempotency-Key: $MKEY" -d "$MBODY"
# frames look like:
# event: job
# data: {...}
#
# event: done
# data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"modernize\",...}"}}
# keep only the final reply text:
api POST /run-stream -N -H "Idempotency-Key: $MKEY" -d "$MBODY" \
| awk '/^event:/{ev=$2} /^data:/ && ev=="done"{sub(/^data: */,""); print}' \
| jq -r '.output.output' | jq '{verdict, headline, target_framework, files: [(.files // [])[] | {path, action}]}'
# the modernize worked example: modernize-files.json and modernize-facts.json
mod_inp = make_input("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.")
mod_key = "csproj-modernizer:modernize:" + hashlib.sha256(json.dumps(mod_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": mod_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(mod_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
print("target_framework:", result.get("target_framework"))
for f in result.get("files", []):
print(f["action"], f["path"])
// the modernize worked example: modernize-files.json and modernize-facts.json
const modInput = makeInput("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.");
const modKey = "csproj-modernizer:modernize:" +
createHash("sha256").update(JSON.stringify(modInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": modKey },
body: JSON.stringify(modInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
console.log("target_framework:", result.target_framework);
for (const f of result.files || []) console.log(f.action, f.path);
// the modernize worked example: modernize-files.json and modernize-facts.json
modInput := makeInput("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.")
modJSON, _ := json.Marshal(modInput)
modSum := sha256.Sum256(modJSON)
modKey := "csproj-modernizer:modernize:" + hex.EncodeToString(modSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", modInput, map[string]string{"Idempotency-Key": modKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
fmt.Println("target_framework:", result["target_framework"])
resultFiles, _ := result["files"].([]any)
for _, x := range resultFiles {
f, _ := x.(map[string]any)
fmt.Println(f["action"], f["path"])
}
// the modernize worked example: modernize-files.json and modernize-facts.json
Map<String, Object> modInput = makeInput("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.");
byte[] modDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(modInput));
String modKey = "csproj-modernizer:modernize:" + HexFormat.of().formatHex(modDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", modInput, Map.of("Idempotency-Key", modKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
System.out.println("target_framework: " + result.path("target_framework").asText());
for (JsonNode f : result.path("files")) {
System.out.println(f.path("action").asText() + " " + f.path("path").asText());
}
# the modernize worked example: modernize-files.json and modernize-facts.json
mod_input = make_input("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.")
mod_key = "csproj-modernizer:modernize:#{Digest::SHA256.hexdigest(JSON.generate(mod_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", mod_input, { "Idempotency-Key" => mod_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
puts "target_framework: #{result["target_framework"]}"
(result["files"] || []).each { |f| puts "#{f["action"]} #{f["path"]}" }
// the modernize worked example: modernize-files.json and modernize-facts.json
$modInput = make_input("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.");
$modKey = "csproj-modernizer:modernize:" . substr(hash("sha256", json_encode($modInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $modKey]),
CURLOPT_POSTFIELDS => json_encode($modInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk;
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
echo "target_framework: ", $result->target_framework ?? "", "\n";
foreach ($result->files ?? [] as $f) echo $f->action, " ", $f->path, "\n";
// the modernize worked example: modernize-files.json and modernize-facts.json
var modInput = MakeInput("modernize", "modernize-files.json", "modernize-facts.json",
"Move this importer to SDK-style first. It stays on .NET Framework 4.8 for now; .NET 8 comes later.");
var modDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(modInput)));
var modKey = "csproj-modernizer:modernize:" + Convert.ToHexString(modDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", modInput, new Dictionary<string, string> { ["Idempotency-Key"] = modKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
Console.WriteLine($"target_framework: {result["target_framework"]}");
foreach (var f in result["files"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["action"]} {f?["path"]}");
What the page does that you now do
- Splits the paste into files and parses them in the browser, and builds
factsfrom them (msbkit.js); you send the files and the same facts, both stringified, or copy the facts from the page’s Download .json export. - Prices every input with
/estimateand refuses to run belowmin_credits. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Checks the reply against the files (
recon.js): evidence, lines, fixes and flag answers for an audit; the inventory, the SDK form and a fresh check of the rewrite for a modernize run.
Or skip all of this and use Csproj Modernizer in the browser: the check, the flags, the legacy inventory and the Markdown and CSV exports are free.