← Perl Clinic
API
Tokens

Run Perl Clinic from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the code for free - every legacy idiom and real hazard the source skills name (the preamble, @_ unpacking, two-argument open, string eval, SQL built by interpolation, a .t without done_testing, and more), each with its rule id, line and fix, gated by the Perl version - in perlscan.js. The run writes the review and the rewrite, or the test file; the page then holds the reply to its own scan and re-scans what came back (perlreply.js).

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"perl-clinic"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, code, version, facts) is missing or not a string. Send every field as a string, version included ("5.36", not 5.36).
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - review (judge the code: a verdict, findings with fixes, a call on every scan finding, and the complete rewritten file) and tests (write the complete Test2::V0 .t file for the code as it is, repairing and extending the one you sent). A missing or unknown task is answered as review, with a caveat saying so. Both lanes send the same fields.

The input

Every field is a string.

FieldRequiredWhat
taskyesreview or tests. The model answers that task and only that task.
codeyesthe module or script. The page clips a long file (over 24,000 characters) by keeping the head and the tail and putting a # ... N lines (a-b) omitted by Perl Clinic ... line in the middle; the model keeps that line and will not guess what was left out.
testsyes (may be "")the existing .t file, or empty when there is none (clipped the same way past 12,000 characters).
versionyesthe Perl the code must run on, for example "5.36", "5.40", "5.26". Nothing the version lacks is ever recommended - on an older Perl the answer is code that runs there plus "on 5.36+ this becomes ...".
factsyesthe page's own scan of the same code: the version, a prescan verdict, the code's shape (packages, public and private subs, exports, Moo attributes), which public subs the .t calls, and every finding as rule | severity | file | line | text. See the facts format.
focusnowhat the developer wants weighed, for example "a bad line should be skipped, not fatal". May be empty; when given, the summary says how it changed the answer.
retry_notenopresent only on a reformat retry: the previous reply could not be read, and this says how to answer again. Leave it out otherwise.

The facts format

facts is the page's scan, written as plain text. An API caller may send their own short summary of the code instead, but the scan's format gets the most out of a run: the review answers every error and warn PL- rule id it finds there (one prescan entry per id), both lanes keep every public sub listed under "Shape", and the tests lane covers every sub listed as never called. The format, line by line:

Perl version: 5.<minor>.
Prescan verdict: <ship|refactor|rework> (<n> errors, <n> warnings, <n> notes).
Shape: <module|script|code>; package <names>; public subs <a, b>; private subs <_c>; exports <names>; attributes <names>.
Tests: the .t calls <n> of <m> public subs; never called: <a, b>.     (or: Tests: no .t file was given.)
Clipped: <only when code or tests were clipped>
Findings (rule | severity | file | line | text):
- <rule id> | <error|warn|info> | <code|tests> | line <n> | <title> (<optional note>) | <the source line>

A list with nothing in it reads (none), and a clean scan has the single finding line - none. A real example - the facts the page sends for the sample used in the worked examples below:

Perl version: 5.38.
Prescan verdict: refactor (0 errors, 2 warnings, 2 notes).
Shape: module; package Ledger::Entry; public subs signed_amount, from_line, load_file, balance; private subs (none); exports (none); attributes account, amount, kind, memo.
Tests: the .t calls 2 of 4 public subs; never called: from_line, load_file.
Findings (rule | severity | file | line | text):
- PL-ATARGS | warn | code | line 17 | @_ unpacking instead of a signature | my ($class, $line, %opt) = @_;
- PL-RETUNDEF | warn | code | line 18 | return undef | return undef unless $line =~ /^(\d{4}-\d{2}-\d{2})\s+(\S+)\s+(-?[\d.]+)\s*(.*)$/;
- PL-POSCAP | info | code | line 19 | Positional captures (4 places in the file.) | my $kind = $3 < 0 ? 'credit' : 'debit';
- PL-EVALIF | info | code | line 27 | eval { } then if ($@) | my $entry = eval { $class->from_line($line) };

Rule ids the scan uses, for the code: PL-NOSTRICT (No strict, error), PL-NOWARN (No warnings, error), PL-BOILER (Legacy preamble, warn), PL-ATARGS (@_ unpacking instead of a signature, warn), PL-PROTO (Prototype, info), PL-RETUNDEF (return undef, warn), PL-OPEN2 (Two-argument open, error), PL-BAREFH (Bareword filehandle, warn), PL-OPENDIE (open not checked, warn), PL-INDIRECT (Indirect object syntax, warn), PL-STREVAL (String eval, error), PL-NOSTRICTREFS (no strict 'refs', warn), PL-SYMREF (Symbolic reference, warn), PL-DBI (SQL built by interpolation, error), PL-OURVAR (Mutable global as configuration, info), PL-CIRCUMFIX (Circumfix dereference, info), PL-POSCAP (Positional captures, info), PL-EVALIF (eval { } then if ($@), info), PL-BLESS (Blessed-hashref class, info), PL-BLESSEDISA (blessed($o) && $o->isa(...), info), PL-ISAEXPORTER (Exporter through @ISA, info), PL-EXPORTALL (@EXPORT (exports by default), warn), PL-TRUE (Module does not end in 1;, error), PL-EACH (each, info), PL-TRYEARLY (Native try/catch before 5.40, error), PL-CLASSEARLY (class keyword before 5.38, error), PL-SAYNOFEAT (say without the feature, error). For the .t file: PT-NODONE (No done_testing (or plan), error), PT-TESTMORE (Test::More, info), PT-ISDEEPLY (is_deeply, info), PT-EVALAT (eval { } and $@ in a test, warn), PT-MONKEY (Monkey-patched sub in a test, error), PT-OUR (our variable in a test, warn), PT-NOSUBTEST (No subtests, info), PT-PLAN (Fixed plan, info). Each rule is gated by the version (the PL-BOILER and PL-ATARGS advice needs 5.36, PL-CIRCUMFIX 5.24; PL-TRUE is not reported from 5.38 on). The review answers the PL- ids; the PT- ids belong to the tests lane. A finding the scan cannot see comes back with the rule NEW.

The standard the model applies

The source skills, @affaan-m/perl-patterns and @affaan-m/perl-testing, are the only standard:

House rules

The replies

review

verdict is ship (modern and correct for its version), refactor (works, with legacy idioms or one real defect) or rework (unsafe or wrong, or legacy throughout). A scan error the model agrees with rules out ship.

  1. summary - two or three sentences: what the code does, the verdict and the first thing to fix.
  2. findings - most severe first: rule (the facts' rule id, such as PL-OPEN2, or NEW), severity (high, medium or low), title, where (such as line 18), why (one or two sentences about this code), fix (the corrected lines).
  3. prescan - one entry per error and warn PL- rule id in the facts: rule, call (agree or disagree), note (one sentence). PT- findings belong to the tests lane.
  4. rewrite - the complete rewritten module or script for the stated version: name (such as Report.pm) and code (the whole file, including what did not change).
  5. kept - anything deliberately left as it was: rule, what, why.
  6. caveats - what could not be seen or verified.
{
  "task": "review",
  "verdict": "refactor",
  "summary": "",
  "findings": [ { "rule": "PL-OPEN2", "severity": "high", "title": "", "where": "line 18", "why": "", "fix": "" } ],
  "prescan": [ { "rule": "PL-OPEN2", "call": "agree", "note": "" } ],
  "rewrite": { "name": "Report.pm", "code": "" },
  "kept": [ { "rule": "", "what": "", "why": "" } ],
  "caveats": [ "" ]
}

The page holds it to its own scan: every scan error and warning answered by rule id, no ship over an undisputed error, no rule id the scan did not report. It re-scans the rewrite: a legacy pattern still present and not listed in kept, a new problem introduced, or a package, public sub or export that disappeared is named. It shows the diff and offers a .patch.

tests

The complete test file for the code as it is (not a rewrite), in Test2::V0, for the stated version. When a .t was sent, it is repaired and extended: its working assertions kept, its PT- findings fixed, and every public sub the facts list as never called covered.

  1. summary - two sentences: what the file covers and what it does not.
  2. file - name (such as t/inventory-report.t) and code, the whole .t ending in done_testing;, with fixture data inline (a heredoc into a temp file).
  3. covers - one entry per public sub: sub (its exact name) and cases (the behaviours asserted, as short phrases).
  4. skipped - subs not tested, each with sub and why. Every public sub in the facts appears in covers or here.
  5. bugs - behaviours the tests will expose as failing against the code as written: sub, what. Empty when none.
  6. run - the command, such as prove -lv t/inventory-report.t.
  7. caveats - what could not be seen (fixtures, classes the code uses).
{
  "task": "tests",
  "summary": "",
  "file": { "name": "t/report.t", "code": "" },
  "covers": [ { "sub": "load_stock", "cases": [ "" ] } ],
  "skipped": [ { "sub": "", "why": "" } ],
  "bugs": [ { "sub": "", "what": "" } ],
  "run": "prove -lv t/report.t",
  "caveats": [ "" ]
}

The page re-scans the returned file: it must use Test2::V0 and end in done_testing, carry no remaining PT- error or warning, call no sub the module does not define, and actually call every sub it claims in covers. It reports which public subs are newly exercised.

Worked examples - both lanes, one module

One half-updated module (a Perl 5.38 Ledger::Entry Moo class with its .t, two scan warnings and two notes, and a focus on bank exports), sent once to each lane. Each input is exactly what the page sends; each reply is a real reply, abbreviated for reading: long strings end in ..., the code fields show only their first lines, and long lists are cut with a ... entry. The real replies are complete JSON with every field in full.

Ledger::Entry (Perl 5.38) - review

Input:

{
 "task": "review",
 "code": "package Ledger::Entry;\nuse v5.36;\nuse Moo;\nuse Types::Standard qw(Str Num Enum);\nuse namespace::autoclean;\n\nhas account => (is => 'ro', isa => Str, required => 1);\nhas amount  => (is => 'ro', isa => Num, required => 1);\nhas kind    => (is => 'ro', isa => Enum[qw(debit credit)], default => 'debit');\nhas memo    => (is => 'ro', isa => Str, default => '');\n\nsub signed_amount($self) {\n    return $self->kind eq 'credit' ? $self->amount : -$self->amount;\n}\n\nsub from_line {\n    my ($class, $line, %opt) = @_;\n    return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n    my $kind = $3 < 0 ? 'credit' : 'debit';\n    return $class->new(account => $2, amount => abs($3), kind => $kind, memo => $4 // $opt{memo} // '');\n}\n\nsub load_file($class, $path) {\n    open my $fh, '<', $path or die \"Cannot open $path: $!\";\n    my @entries;\n    while (my $line = <$fh>) {\n        my $entry = eval { $class->from_line($line) };\n        if ($@) { warn \"skipped: $@\"; next }\n        push @entries, $entry if $entry;\n    }\n    return @entries;\n}\n\nsub balance($class, @entries) {\n    my $sum = 0;\n    $sum += $_->signed_amount for @entries;\n    return sprintf('%.2f', $sum);\n}\n\n1;",
 "tests": "use v5.36;\nuse Test2::V0;\nuse Ledger::Entry;\n\nsubtest 'signed amounts' => sub {\n    my $d = Ledger::Entry->new(account => 'cash', amount => 10);\n    my $c = Ledger::Entry->new(account => 'cash', amount => 4, kind => 'credit');\n    is($d->signed_amount, -10, 'debit is negative');\n    is($c->signed_amount, 4, 'credit is positive');\n};\n\nsubtest 'balance' => sub {\n    my @e = map { Ledger::Entry->new(account => 'cash', amount => $_) } 1, 2;\n    is(Ledger::Entry->balance(@e), '-3.00', 'sums debits');\n};\n\ndone_testing;",
 "version": "5.38",
 "facts": "Perl version: 5.38.\nPrescan verdict: refactor (0 errors, 2 warnings, 2 notes).\nShape: module; package Ledger::Entry; public subs signed_amount, from_line, load_file, balance; private subs (none); exports (none); attributes account, amount, kind, memo.\nTests: the .t calls 2 of 4 public subs; never called: from_line, load_file.\nFindings (rule | severity | file | line | text):\n- PL-ATARGS | warn | code | line 17 | @_ unpacking instead of a signature | my ($class, $line, %opt) = @_;\n- PL-RETUNDEF | warn | code | line 18 | return undef | return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n- PL-POSCAP | info | code | line 19 | Positional captures (4 places in the file.) | my $kind = $3 < 0 ? 'credit' : 'debit';\n- PL-EVALIF | info | code | line 27 | eval { } then if ($@) | my $entry = eval { $class->from_line($line) };",
 "focus": "Entries come from bank exports; a bad line should be skipped, not fatal."
}

Reply (abbreviated). The prescan said refactor and the review kept it, agreeing with all four scan findings; weighing the focus on bank exports, it led with six NEW findings the scan cannot see (malformed amounts read as wrong numbers, an unused memo option, a BOM that drops the first line) and returned the full rewritten Ledger/Entry.pm:

{
 "task": "review",
 "verdict": "refactor",
 "summary": "Ledger::Entry is a typed Moo class that parses dated bank-export lines into debit/credit entries, loads them from a file and sums a balance; it is modern apart from from_line, so the verdict is refactor. Because of the focus on bank exports, the first fix is ...",
 "findings": [
  {
   "rule": "NEW",
   "severity": "high",
   "title": "Malformed amounts are accepted as wrong numbers",
   "where": "line 18-20",
   "why": "(-?[\\d.]+)\\s*(.*) matches \"1.2.3\" (abs gives 1.2 with an 'isn't numeric' warning), \".\" (amount 0) and, for \"1,234.56\", captures 1 as the ...",
   "fix": "(?<amount>  -? (?: \\d+ (?: \\. \\d* )? | \\. \\d+ ) )\n(?: \\s+ (?<memo> \\S .*? ) )?\n..."
  },
  {
   "rule": "NEW",
   "severity": "medium",
   "title": "The memo option is never used",
   "where": "line 20",
   "why": "(.*) always participates in the match, so $4 is '' rather than undef when the line has no memo, and $4 // $opt{memo} never reaches ...",
   "fix": "(?: \\s+ (?<memo> \\S .*? ) )?   # memo capture is undef when absent\nmemo => $field{memo} // $opt{memo} // '',"
  },
  {
   "rule": "NEW",
   "severity": "medium",
   "title": "No encoding layer; a UTF-8 BOM drops the first transaction",
   "where": "line 24",
   "why": "The file is read as raw bytes, so a non-ASCII memo arrives undecoded, and an export that starts with a UTF-8 BOM makes line 1 fail the ...",
   "fix": "open my $fh, '<:encoding(UTF-8)', $path or die \"Cannot open $path: $!\";\n...\n..."
  },
  {
   "rule": "PL-RETUNDEF",
   "severity": "medium",
   "title": "return undef for a line that does not parse",
   "where": "line 18",
   "why": "In list context, e.g. map { Ledger::Entry->from_line($_) } @lines, this returns a one-element list (undef) and the caller gets an undef ...",
   "fix": "return unless defined $line && $line =~ $LINE_RE;"
  },
  {
   "rule": "NEW",
   "severity": "medium",
   "title": "Dropped dated lines are silent",
   "where": "line 29",
   "why": "When from_line declines a line, load_file pushes nothing and says nothing, so a transaction line with a bad amount disappears from the ...",
   "fix": "elsif ($line =~ $DATED_RE) {\n    warn \"skipped: $path line $.: not a ledger line\\n\";\n..."
  },
  {
   "rule": "...",
   "note": "five more findings, lower severity"
  }
 ],
 "prescan": [
  {
   "rule": "PL-ATARGS",
   "call": "agree",
   "note": "from_line is the only sub without a signature; sub from_line($class, $line, %opt) is a drop-in on 5.38."
  },
  {
   "rule": "PL-RETUNDEF",
   "call": "agree",
   "note": "return undef yields (undef) in list context; a bare return is correct and load_file already tests truthiness."
  },
  {
   "rule": "PL-POSCAP",
   "call": "agree",
   "note": "Named captures in a precompiled qr//x make the line format readable."
  },
  {
   "rule": "PL-EVALIF",
   "call": "agree",
   "note": "Replaced with Try::Tiny, since native try is still experimental on 5.38."
  }
 ],
 "rewrite": {
  "name": "Ledger/Entry.pm",
  "code": "package Ledger::Entry;\nuse v5.36;\nuse Moo;\nuse Types::Standard qw(Str Num Enum);\nuse Try::Tiny;\nuse namespace::autoclean;\n\nhas account => (is => 'ro', isa => Str, required => 1);\nhas amount  => (is => 'ro', isa => Num, required => 1);\nhas kind    => (is => 'ro', isa => Enum[qw(debit credit)], default => 'debit');\nhas memo    => (is => 'ro', isa => Str, default => '');\n\n..."
 },
 "kept": [
  {
   "rule": "NEW",
   "what": "The sign convention: a negative amount in the file becomes a credit, and signed_amount returns credits as positive and debits as negative.",
   "why": "This inverts the file's sign (\"-4\" reads back as +4), which may be deliberate accounting or a bug; the existing .t pins the debit-negative ..."
  },
  {
   "rule": "NEW",
   "what": "The parsed date is matched but not stored.",
   "why": "Adding a date attribute extends the API; it is captured as (?<date>) so it is easy to add."
  },
  {
   "rule": "NEW",
   "what": "Floating-point money and use v5.36 with a final 1;",
   "why": "Integer cents or Math::BigFloat would change amount's type; use v5.36 still runs on 5.38 and 1; is harmless."
  },
  {
   "rule": "NEW",
   "what": "Plain open rather than Path::Tiny.",
   "why": "Path::Tiny is a new dependency; the three-argument open with an encoding layer already meets the rule."
  }
 ],
 "caveats": [
  "Try::Tiny is a new dependency; if the project cannot take it, use eval { ...; 1 } or do { ... } instead.",
  "I assumed bank exports are UTF-8; a Latin-1 or Windows-1252 export needs a different layer, such as '<:encoding(cp1252)'.",
  "No sample export was shown, so the stricter amount pattern (no thousands separators, no currency symbols) may drop lines your banks really ...",
  "Nothing was run: run perl -c lib/Ledger/Entry.pm and prove -l; the existing .t never calls from_line or load_file."
 ]
}

Ledger::Entry (Perl 5.38) - tests

Input - the same fields, with task set to tests:

{
 "task": "tests",
 "code": "package Ledger::Entry;\nuse v5.36;\nuse Moo;\nuse Types::Standard qw(Str Num Enum);\nuse namespace::autoclean;\n\nhas account => (is => 'ro', isa => Str, required => 1);\nhas amount  => (is => 'ro', isa => Num, required => 1);\nhas kind    => (is => 'ro', isa => Enum[qw(debit credit)], default => 'debit');\nhas memo    => (is => 'ro', isa => Str, default => '');\n\nsub signed_amount($self) {\n    return $self->kind eq 'credit' ? $self->amount : -$self->amount;\n}\n\nsub from_line {\n    my ($class, $line, %opt) = @_;\n    return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n    my $kind = $3 < 0 ? 'credit' : 'debit';\n    return $class->new(account => $2, amount => abs($3), kind => $kind, memo => $4 // $opt{memo} // '');\n}\n\nsub load_file($class, $path) {\n    open my $fh, '<', $path or die \"Cannot open $path: $!\";\n    my @entries;\n    while (my $line = <$fh>) {\n        my $entry = eval { $class->from_line($line) };\n        if ($@) { warn \"skipped: $@\"; next }\n        push @entries, $entry if $entry;\n    }\n    return @entries;\n}\n\nsub balance($class, @entries) {\n    my $sum = 0;\n    $sum += $_->signed_amount for @entries;\n    return sprintf('%.2f', $sum);\n}\n\n1;",
 "tests": "use v5.36;\nuse Test2::V0;\nuse Ledger::Entry;\n\nsubtest 'signed amounts' => sub {\n    my $d = Ledger::Entry->new(account => 'cash', amount => 10);\n    my $c = Ledger::Entry->new(account => 'cash', amount => 4, kind => 'credit');\n    is($d->signed_amount, -10, 'debit is negative');\n    is($c->signed_amount, 4, 'credit is positive');\n};\n\nsubtest 'balance' => sub {\n    my @e = map { Ledger::Entry->new(account => 'cash', amount => $_) } 1, 2;\n    is(Ledger::Entry->balance(@e), '-3.00', 'sums debits');\n};\n\ndone_testing;",
 "version": "5.38",
 "facts": "Perl version: 5.38.\nPrescan verdict: refactor (0 errors, 2 warnings, 2 notes).\nShape: module; package Ledger::Entry; public subs signed_amount, from_line, load_file, balance; private subs (none); exports (none); attributes account, amount, kind, memo.\nTests: the .t calls 2 of 4 public subs; never called: from_line, load_file.\nFindings (rule | severity | file | line | text):\n- PL-ATARGS | warn | code | line 17 | @_ unpacking instead of a signature | my ($class, $line, %opt) = @_;\n- PL-RETUNDEF | warn | code | line 18 | return undef | return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n- PL-POSCAP | info | code | line 19 | Positional captures (4 places in the file.) | my $kind = $3 < 0 ? 'credit' : 'debit';\n- PL-EVALIF | info | code | line 27 | eval { } then if ($@) | my $entry = eval { $class->from_line($line) };",
 "focus": "Entries come from bank exports; a bad line should be skipped, not fatal."
}

Reply (abbreviated). The two existing subtests were kept, from_line and load_file (never called by the old .t) are now covered along with the four Moo attributes, and eight todo blocks encode intended behaviour the current code gets wrong, each listed in bugs:

{
 "task": "tests",
 "summary": "Keeps the two existing subtests and adds cases for all four public subs: the Moo attribute defaults and type checks, from_line on debits, credits, zero, CRLF and rejected lines, load_file on a temp export (headers, blank lines, an empty file, a missing file, ...",
 "file": {
  "name": "t/ledger-entry.t",
  "code": "use v5.36;\nuse Test2::V0;\nuse Test::MockModule;\nuse File::Temp qw(tempdir);\nuse File::Spec;\n\nuse Ledger::Entry;\n\n# Writes raw bytes to a fresh temp file and returns its path.\nsub fixture($content) {\n    my $path = File::Spec->catfile(tempdir(CLEANUP => 1), 'export.txt');\n    open my $out, '>:raw', $path or die \"Cannot write $path: $!\";\n    print {$out} $content;\n    close $out or die \"Cannot close $path: $!\";\n    return $path;\n}\n..."
 },
 "covers": [
  {
   "sub": "signed_amount",
   "cases": [
    "debit is negative",
    "credit is positive",
    "zero debit is zero"
   ]
  },
  {
   "sub": "from_line",
   "cases": [
    "positive amount becomes a debit with memo",
    "negative amount becomes an unsigned credit",
    "missing memo is empty",
    "..."
   ]
  },
  {
   "sub": "load_file",
   "cases": [
    "loads entries in file order, skipping header, blank and junk lines",
    "balance of the loaded file",
    "empty file gives no entries",
    "..."
   ]
  },
  {
   "sub": "balance",
   "cases": [
    "sums debits",
    "empty list is 0.00",
    "credits offset debits",
    "..."
   ]
  },
  {
   "sub": "account",
   "cases": [
    "required"
   ]
  },
  {
   "sub": "amount",
   "cases": [
    "rejects a non-numeric value"
   ]
  },
  {
   "sub": "kind",
   "cases": [
    "defaults to debit",
    "rejects a value outside debit and credit"
   ]
  },
  {
   "sub": "memo",
   "cases": [
    "defaults to empty string"
   ]
  }
 ],
 "skipped": [],
 "bugs": [
  {
   "sub": "from_line",
   "what": "return undef on line 18 gives (undef) in list context, so a rejected line is a one-element list."
  },
  {
   "sub": "from_line",
   "what": "The memo option is never used: (.*) always matches, so $4 is '' not undef and $4 // $opt{memo} never falls through."
  },
  {
   "sub": "from_line",
   "what": "A CRLF line with a memo keeps the \\r (memo \"rent\\r\")."
  },
  {
   "sub": "...",
   "what": "five more"
  }
 ],
 "run": "prove -lv t/ledger-entry.t",
 "caveats": [
  "Assumes the module lives at lib/Ledger/Entry.pm so prove -l finds it, and that Moo, Types::Standard, namespace::autoclean and ...",
  "No real bank export was shown; the fixtures are inline guesses at the 'YYYY-MM-DD account amount memo' format the regex expects.",
  "The type-check messages are matched loosely (the value or attribute name), since their exact text comes from Types::Standard and Moo.",
  "..."
 ]
}

Build the input yourself

The scanner and the reply checker are plain scripts that run under node (global.window = global; require("./perlscan.js"); require("./perlreply.js")), then:

const S = PerlScan, R = PerlReply;
const st = { code: fs.readFileSync("lib/Ledger/Entry.pm", "utf8"), tests: fs.readFileSync("t/ledger-entry.t", "utf8"),
             version: "5.38", focus: "Entries come from bank exports; a bad line should be skipped, not fatal." };
const scan = S.scan(st);                        // version, verdict, counts, shape, coverage, findings
const b = S.buildInput(st, "review");           // or "tests"; clips long files and writes the facts
const wire = S.toWire(b.input);                 // every field a string - POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                            // {notes, warn}: anything the reply missed or got wrong

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://perl-clinic.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"perl-clinic"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}'

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save one of the worked-example inputs above as input.json to follow along.

# input.json: the run input object - {"task": "review", "code": "package Ledger::Entry; ...", "tests": "use v5.36; ...", "version": "5.38", "facts": "Perl version: 5.38. ...", "focus": "..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses perl-clinic:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a test file for the same module are two runs - and its one automatic reformat retry (sent with a retry_note) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. A review carries verdict, summary, findings, prescan, rewrite, kept and caveats; a test file carries summary, file, covers, skipped, bugs, run and caveats. The code below prints the headline parts of either: for a review the verdict, the summary and each finding's severity and title; for tests the summary, the file name and each covered sub.

KEY="perl-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "tests"
  then .summary, "file: \(.file.name)", (.covers[] | "covers: \(.sub)")
  else "verdict: \(.verdict)", .summary, (.findings[] | "[\(.severity)] \(.title)") end' reply.json

Step 6 — POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: perl-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json