Skip to content

Commit bf13906

Browse files
MostCromulentclaude
andcommitted
Validate card-script API names and sub-ability refs against the engine
Add a pure-advisory test (CardScriptApiTest) that checks each card against the engine's own definitions and writes findings for the review workflow to post, never failing the build: - ability/trigger/replacement API names via ApiType, TriggerType and ReplacementType (the same smartValueOf the engine runs at card load), catching a typo'd API such as DB$ DealDamge that the linter cannot; - sub-ability params in AbilityFactory.additionalAbilityKeys must point at a defined SVar, closing a gap where the linter only knew a handful of these keys. cardlint's REF_KEYS drops the now-engine-checked keys. The analysis runs inside the build (mvn test). A pull_request build has a read-only token on fork PRs and so cannot comment; the review workflow is therefore reworked from pull_request_target into a workflow_run poster. test-build uploads the findings and the PR number as an artifact, and the poster downloads it, runs the linter and Scryfall frame check, and posts the merged, deduped comments on the changed lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9b8904b commit bf13906

5 files changed

Lines changed: 341 additions & 67 deletions

File tree

.github/scripts/card_script_review.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
When the card is not on Scryfall (e.g. an unreleased card) it stays silent;
1818
see scryfall_facts().
1919
20+
3. Engine API findings (optional, --java-findings) — ability/trigger/replacement
21+
API names validated by the build against the engine's own enums, the same
22+
check the engine runs at card load. This is the one engine-coupled input, and
23+
only to the stable API vocabulary; it is produced by the build, not here.
24+
2025
This file never interprets ability semantics. It only (a) relays the linter's
2126
structured findings and (b) diffs frame fields — both decoupled from how any
2227
individual card is scripted, which is what keeps it stable across engine changes.
@@ -118,11 +123,15 @@ def grab(pat):
118123
elif code == "LEX-PIPE":
119124
return "add a space around the `|` separator"
120125
elif code == "LEX-CURLY":
121-
return "curly apostrophe `’` " + ARROW + " ASCII `'`"
126+
return f"curly apostrophe `’` {ARROW} ASCII `'`"
122127
elif code == "MANA":
123128
t = grab(r"token '([^']+)'")
124129
if t:
125130
return f"illegal mana token `{t}`"
131+
elif code == "API-UNKNOWN":
132+
m = re.match(r"(\w+\$) '([^']+)' is not a known (.+)", msg)
133+
if m:
134+
return f"`{m.group(1)} {m.group(2)}` {ARROW} unknown {m.group(3)}"
126135
# default: the linter message is already human-readable
127136
return msg
128137

@@ -288,31 +297,57 @@ def scryfall_facts(path):
288297
# --------------------------------------------------------------------------- #
289298
# main
290299
# --------------------------------------------------------------------------- #
300+
def java_findings_comments(path, changed):
301+
"""Load engine-validated API findings emitted by the build, scoped to changed cards.
302+
303+
The build's Java test validates every card's ability/trigger/replacement API
304+
names against the engine's own enums and writes {path, line, code, body}. We
305+
only keep findings on cards this PR changed; the workflow further scopes them
306+
to the diff lines before posting.
307+
"""
308+
try:
309+
items = json.load(open(path, encoding="utf-8"))
310+
except Exception as e:
311+
print(f"no java findings ({e})", file=sys.stderr)
312+
return []
313+
out = []
314+
for f in items:
315+
p = f.get("path", "").replace("\\", "/")
316+
if p in changed:
317+
out.append({"path": p, "line": f["line"],
318+
"body": terse_comment(f.get("code", ""), f.get("body", ""))})
319+
return out
320+
321+
291322
def main():
292-
if len(sys.argv) < 2:
293-
print("usage: card_script_review.py <changed_files.txt>", file=sys.stderr)
323+
args = sys.argv[1:]
324+
java_path = None
325+
if "--java-findings" in args:
326+
i = args.index("--java-findings")
327+
java_path = args[i + 1]
328+
del args[i:i + 2]
329+
if not args:
330+
print("usage: card_script_review.py [--java-findings <file>] <changed_files.txt>",
331+
file=sys.stderr)
294332
return 2
295-
paths = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
296-
cards = [p for p in paths if p.endswith(".txt") and "cardsfolder" in p.replace("\\", "/")]
333+
paths = [l.strip() for l in open(args[0], encoding="utf-8") if l.strip()]
334+
cards = [p for p in paths if p.endswith(".txt")
335+
and "cardsfolder" in p.replace("\\", "/") and os.path.exists(p)]
297336

298337
# The workflow materializes the PR's cards into the corpus, so a freshly
299338
# computed key_freq counts them too. Subtract each reviewed card's own param
300339
# counts so a typo'd or made-up param present only in the reviewed card isn't
301340
# self-counted as "known" (which would silence KEY-TYPO / UNKNOWN-KEY).
302341
freq = dict(cardlint.key_freq(cardlint.find_corpus()) or {})
303342
for path in cards:
304-
if not os.path.exists(path):
305-
continue
306343
text = open(path, encoding="utf-8", errors="ignore").read()
307-
for k, n in collections.Counter(re.findall(r"([A-Za-z][A-Za-z0-9]*)\$", text)).items():
344+
for k, n in collections.Counter(cardlint.KEY_TOKEN.findall(text)).items():
308345
if k in freq:
309346
freq[k] -= n
310347
if freq[k] <= 0:
311348
del freq[k]
312349
comments = []
313350
for path in cards:
314-
if not os.path.exists(path): # deleted in the PR
315-
continue
316351
found = []
317352
try:
318353
found += lint_comments(path, freq)
@@ -325,6 +360,9 @@ def main():
325360
for line, body in found:
326361
comments.append({"path": path.replace("\\", "/"), "line": line, "body": body})
327362

363+
if java_path:
364+
comments += java_findings_comments(java_path, set(c.replace("\\", "/") for c in cards))
365+
328366
json.dump(comments, sys.stdout, ensure_ascii=False, indent=2)
329367
print(f"\n{len(comments)} comment(s) across {len(cards)} card(s)", file=sys.stderr)
330368
return 0

.github/scripts/cardlint.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@
1414
forge-gui/res/cardsfolder near cwd; skipped if not found).
1515
Exit: 1 if any findings, else 0.
1616
"""
17-
import re, sys, os, json, tempfile, hashlib
17+
import re, sys, os, json, time, tempfile, hashlib
18+
19+
KEY_TOKEN = re.compile(r"([A-Za-z][A-Za-z0-9]*)\$") # a `key$` param token
1820

1921
LINE_PREFIXES = {"Name","ManaCost","Types","PT","Loyalty","Defense","Colors","Text",
2022
"Oracle","K","A","T","S","R","SVar","AI","DeckHints","DeckNeeds","DeckHas",
2123
"AlternateMode","Variant","ALTERNATE","SetColor"}
2224
PFX_LOWER = {p.lower():p for p in LINE_PREFIXES}
23-
REF_KEYS = {"Execute","SubAbility","TrueSubAbility","FalseSubAbility","AbilityX",
24-
"RepeatSubAbility","ChosenSubAbility"}
25+
# The sub-ability keys in the engine's additionalAbilityKeys are validated against
26+
# defined SVars by the build's Java test; only the keys outside that list stay here.
27+
REF_KEYS = {"Execute","SubAbility","AbilityX","ChosenSubAbility"}
2528
LIST_REF_KEYS = {"Choices"}
2629
AMP_LIST_KEYS = {"AddTypes","AddKeyword","AddKeywords","RemoveKeywords",
2730
"AddTrigger","AddStatic","AddReplacement","Triggers"}
@@ -80,7 +83,7 @@ def key_freq(corpus):
8083
cache=os.path.join(tempfile.gettempdir(),
8184
"cardlint_keyfreq_"+hashlib.md5(corpus.encode()).hexdigest()[:8]+".json")
8285
try:
83-
if os.path.exists(cache) and (os.path.getmtime(cache) > __import__("time").time()-7*86400):
86+
if os.path.exists(cache) and (os.path.getmtime(cache) > time.time()-7*86400):
8487
return json.load(open(cache,encoding="utf-8"))
8588
except Exception: pass
8689
freq={}
@@ -89,7 +92,7 @@ def key_freq(corpus):
8992
if not fn.endswith(".txt"): continue
9093
try: txt=open(os.path.join(root,fn),encoding="utf-8",errors="ignore").read()
9194
except Exception: continue
92-
for k in re.findall(r"([A-Za-z][A-Za-z0-9]*)\$",txt):
95+
for k in KEY_TOKEN.findall(txt):
9396
freq[k]=freq.get(k,0)+1
9497
try: json.dump(freq,open(cache,"w",encoding="utf-8"))
9598
except Exception: pass

.github/workflows/card-script-review.yml

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,134 @@
1-
# On a PR that touches card scripts, runs a deterministic linter + a Scryfall
2-
# frame fact-check and posts terse inline comments. It is purely ADVISORY: it
3-
# never fails the check, it only comments. A reviewer still decides what to act on.
1+
# Posts terse, advisory inline comments on PRs that touch card scripts: a
2+
# deterministic DSL linter, a Scryfall frame fact-check, and engine-validated API
3+
# names. It only comments; it never fails a check. A reviewer decides what to act on.
44
#
5-
# Trigger: pull_request_target, because almost all PRs come from forks and a plain
6-
# pull_request token is read-only (can't comment). pull_request_target runs in the
7-
# base repo with a write token, so the hard rule is: NEVER execute PR code.
8-
# This workflow upholds that — it runs only the BASE repo's scripts (checked out
9-
# from the target branch) and pulls in just the PR's `cardsfolder/*.txt` files as
10-
# DATA to lint. The changed-file filter excludes `.github/**`, so a PR cannot swap
11-
# in its own linter, and nothing from the PR is ever built or executed.
5+
# Why this is a separate workflow from the build. The analysis runs inside the
6+
# build (the Card-script review test in `mvn test` + the linter here), but a
7+
# `pull_request` build has a READ-ONLY token on fork PRs — almost all card PRs —
8+
# so it cannot post comments. `workflow_run` runs after the build completes in the
9+
# base-repo context with a write token, which is GitHub's supported pattern for
10+
# "untrusted build produces findings, a privileged job posts them". The build
11+
# itself stays on the safe read-only token; only this poster can write.
1212
#
13-
# Stable across engine changes: the linter derives "known params" from the corpus
14-
# itself, and the Scryfall side only compares stable frame fields (name/type/P-T/
15-
# cost/loyalty). Neither is coupled to how a card ability is scripted.
13+
# Trust boundary: the build that produced the artifact ran PR code, so the
14+
# downloaded files are treated as DATA only (parsed, never executed). The linter
15+
# and corpus are the BASE repo's, checked out fresh here; the PR's card files are
16+
# pulled in as data to lint, never run.
1617

1718
name: Card-script review
1819

1920
on:
20-
pull_request_target:
21-
paths:
22-
- 'forge-gui/res/cardsfolder/**'
21+
workflow_run:
22+
workflows: ["Test build"]
23+
types: [completed]
2324

2425
permissions:
2526
contents: read
27+
actions: read
2628
pull-requests: write
2729

2830
concurrency:
29-
group: card-script-review-${{ github.event.pull_request.number }}
31+
group: card-script-review-${{ github.event.workflow_run.head_branch }}
3032
cancel-in-progress: true
3133

3234
jobs:
3335
review:
3436
runs-on: ubuntu-latest
37+
# Only PR builds carry review data; push builds have nothing to comment on.
38+
if: github.event.workflow_run.event == 'pull_request'
3539
steps:
36-
# Trusted base checkout: the scripts we run and the card corpus the linter
37-
# compares against. NOT the PR's code.
38-
- name: Check out base repo
39-
uses: actions/checkout@v4
40-
41-
- name: Set up Python
42-
uses: actions/setup-python@v5
40+
- name: Download review data from the build
41+
uses: actions/download-artifact@v4
4342
with:
44-
python-version: '3.11'
43+
name: card-script-review
44+
run-id: ${{ github.event.workflow_run.id }}
45+
github-token: ${{ secrets.GITHUB_TOKEN }}
46+
path: review-data
47+
continue-on-error: true # no artifact (non-card PR) -> nothing to do
4548

46-
- name: List changed card files
47-
uses: actions/github-script@v7
49+
- name: Resolve PR and changed cards
50+
id: pr
51+
uses: actions/github-script@v8
4852
with:
4953
script: |
5054
const fs = require('fs');
55+
let meta;
56+
try { meta = JSON.parse(fs.readFileSync('review-data/card-script-pr-meta.json', 'utf8')); }
57+
catch (e) { core.info('no PR metadata — nothing to review'); return; }
58+
const pull_number = meta.number;
5159
const files = await github.paginate(github.rest.pulls.listFiles, {
52-
owner: context.repo.owner,
53-
repo: context.repo.repo,
54-
pull_number: context.issue.number,
55-
});
56-
// Only cardsfolder .txt files — this also guarantees we never pull a
57-
// PR's version of .github/** (incl. the scripts we're about to run).
60+
owner: context.repo.owner, repo: context.repo.repo, pull_number });
5861
const cards = files.filter(f => f.status !== 'removed'
5962
&& f.filename.startsWith('forge-gui/res/cardsfolder/')
6063
&& f.filename.endsWith('.txt'));
64+
if (!cards.length) { core.info('no card files changed'); return; }
65+
core.setOutput('pull_number', String(pull_number));
66+
core.setOutput('head_sha', meta.head_sha);
6167
fs.writeFileSync('changed_files.txt', cards.map(f => f.filename).join('\n'));
6268
63-
// For each card, the set of NEW-file line numbers it actually adds or
64-
// changes (the '+' lines of the diff). We only comment on these — a
65-
// finding on a line the PR didn't touch is pre-existing and out of scope.
66-
// For an added file the whole file is '+' lines, so nothing is lost.
69+
// For each card, the NEW-file line numbers it adds or changes (the '+'
70+
// lines). We only comment on these — a finding on an untouched line of an
71+
// edited file is pre-existing and out of scope.
6772
const addedLines = {};
6873
for (const f of cards) {
69-
const lines = [];
70-
let newLine = 0;
74+
const lines = []; let newLine = 0;
7175
for (const ln of (f.patch || '').split('\n')) {
7276
const h = ln.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
7377
if (h) { newLine = parseInt(h[1], 10); continue; }
7478
if (ln.startsWith('\\')) continue; // "\ No newline at end of file"
7579
if (ln.startsWith('+')) { lines.push(newLine); newLine++; }
76-
else if (ln.startsWith('-')) { /* old-side only: no new line */ }
80+
else if (ln.startsWith('-')) { /* old-side only */ }
7781
else { newLine++; } // context line
7882
}
7983
addedLines[f.filename] = lines;
8084
}
8185
fs.writeFileSync('diff_lines.json', JSON.stringify(addedLines));
8286
core.info(`${cards.length} changed card file(s)`);
8387
84-
# Bring in the PR's versions of those card files as plain data, overwriting
88+
- name: Check out base scripts and corpus
89+
if: steps.pr.outputs.pull_number != ''
90+
uses: actions/checkout@v5
91+
92+
- name: Set up Python
93+
if: steps.pr.outputs.pull_number != ''
94+
uses: actions/setup-python@v6
95+
with:
96+
python-version: '3.11'
97+
98+
# Bring in the PR's versions of the changed cards as plain data, overwriting
8599
# the base copies in place. We only read them; we never run them.
86100
- name: Materialize PR card files (data only)
101+
if: steps.pr.outputs.pull_number != ''
87102
env:
88-
PR_NUMBER: ${{ github.event.pull_request.number }}
103+
PR_NUMBER: ${{ steps.pr.outputs.pull_number }}
89104
run: |
90105
[ -s changed_files.txt ] || { echo "no card files changed"; exit 0; }
91106
git fetch --no-tags --depth=1 origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head"
92-
# `|| [ -n "$f" ]` processes the final line even when the file has no
93-
# trailing newline (changed_files.txt is written with join('\n')).
107+
# `|| [ -n "$f" ]` processes the final line even without a trailing newline.
94108
while IFS= read -r f || [ -n "$f" ]; do
95109
[ -n "$f" ] || continue
96110
mkdir -p "$(dirname "$f")"
97111
git show "refs/remotes/pr/head:$f" > "$f" || echo "skip $f"
98112
done < changed_files.txt
99113
100114
- name: Run review checks
115+
if: steps.pr.outputs.pull_number != ''
101116
continue-on-error: true # advisory: a script hiccup must never fail the PR
102117
env:
103-
PYTHONIOENCODING: utf-8 # the comments contain '→'; don't depend on runner locale
118+
PYTHONIOENCODING: utf-8 # comments contain '→'; don't depend on runner locale
104119
run: |
105-
python .github/scripts/card_script_review.py changed_files.txt > comments.json
120+
JF=review-data/card-script-findings.json
121+
ARGS=""; [ -f "$JF" ] && ARGS="--java-findings $JF"
122+
python .github/scripts/card_script_review.py $ARGS changed_files.txt > comments.json
106123
echo "---- comments ----"
107124
cat comments.json
108125
109126
- name: Post inline comments
110-
uses: actions/github-script@v7
127+
if: steps.pr.outputs.pull_number != ''
128+
uses: actions/github-script@v8
129+
env:
130+
PULL_NUMBER: ${{ steps.pr.outputs.pull_number }}
131+
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
111132
with:
112133
script: |
113134
const fs = require('fs');
@@ -119,12 +140,10 @@ jobs:
119140
if (!comments.length) { core.info('no findings'); return; }
120141
121142
const { owner, repo } = context.repo;
122-
const pull_number = context.issue.number;
123-
const commit_id = context.payload.pull_request.head.sha;
143+
const pull_number = Number(process.env.PULL_NUMBER);
144+
const commit_id = process.env.HEAD_SHA;
124145
125-
// Diff-lines-only: comment only on lines the PR added or changed. A
126-
// finding on an untouched line of an edited file is pre-existing and
127-
// out of scope, so we skip it (no summary comment).
146+
// Diff-lines-only: comment only on lines the PR added or changed.
128147
const inDiff = (c) => (diffLines[c.path] || []).includes(c.line);
129148
130149
// De-dupe against what the bot already said on this PR (re-runs on push).

.github/workflows/test-build.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,28 @@ jobs:
2727
export DISPLAY=":1"
2828
Xvfb :1 -screen 0 800x600x8 &
2929
mvn -U -B clean test
30+
31+
# The card-script review test writes findings here; hand them, plus the PR
32+
# number, to the Card-script review workflow. A pull_request build has a
33+
# read-only token on fork PRs and cannot comment, so posting happens there.
34+
# One matrix leg only, and always() so a test failure still ships findings.
35+
- name: Stage card-script review data
36+
if: ${{ always() && github.event_name == 'pull_request' && matrix.java == '17' }}
37+
run: |
38+
printf '{"number": %s, "head_sha": "%s"}\n' \
39+
"${{ github.event.pull_request.number }}" \
40+
"${{ github.event.pull_request.head.sha }}" > card-script-pr-meta.json
41+
# Stage at repo root so both files sit at the artifact's top level.
42+
[ -f forge-game/target/card-script-findings.json ] \
43+
&& cp forge-game/target/card-script-findings.json card-script-findings.json || true
44+
45+
- name: Upload card-script review data
46+
if: ${{ always() && github.event_name == 'pull_request' && matrix.java == '17' }}
47+
uses: actions/upload-artifact@v4
48+
with:
49+
name: card-script-review
50+
path: |
51+
card-script-findings.json
52+
card-script-pr-meta.json
53+
if-no-files-found: ignore
54+
retention-days: 1 # only needed until the poster runs minutes later

0 commit comments

Comments
 (0)