Skip to content

fix(pstack): statechart refuses foreign files nested under tlc/ or behind an owned name - #785

Open
deanrie wants to merge 4 commits into
cursor:mainfrom
deanrie:pstack/statechart-foreign-recursive
Open

deanrie wants to merge 4 commits into
cursor:mainfrom
deanrie:pstack/statechart-foreign-recursive

Conversation

@deanrie

@deanrie deanrie commented Oct 23, 2026 •

Copy link
Copy Markdown

Fixes #773.

Problem

foreign() matched the OWN regex against the output folder's immediate entry names and nothing else: no recursion, no entry-type check. publish() then removes the previous output folder with fs.rmSync(old, { recursive: true, force: true }). So three cases from the report pass the check and are deleted with the folder:

  • tlc/notes.md, a user's file inside the owned tlc directory;
  • a directory named chart.mmd (an owned file name on a folder);
  • a directory named .DS_Store.

A root-level notes.md was refused correctly. This contradicts the contract stated in #741: a run refuses files it did not write.

Change

  • OWN now describes top-level files only. Inside tlc/, each run records the exact filenames it wrote in tlc/.written.json, and the next run treats anything not in that record (or any entry that is not a regular file) as foreign. An earlier revision of this PR matched tlc/ contents by extension (*.cfg, *.out, *.dot), which still let a rerun delete a user's Upload-backup.cfg; @kam-hak reproduced that with real TLC and contributed the record-based check (commits 494ae7d, 726c902).
  • foreign() reads entries with withFileTypes, owns tlc only as a directory and checks its contents one level down, and treats anything that is not a regular file under an owned name (a folder, a symlink) as foreign, reported by its path (tlc/notes.md).
  • publish() and refuseForeign() are unchanged; the existing refusal message now names the nested path.

The symlink note in the report is handled the same way: a link named chart.mmd is refused rather than removed, and its target is untouched.

Tests

scripts/test/statechart.test.mjs: one new test with five fixtures, each run as from-spec Pay.tla --out <folder> with a stub tla2tools.jar so the check runs before TLC would:

  • tlc/notes.md refused, the file still present afterwards;
  • a folder named chart.mmd refused;
  • a folder named .DS_Store refused;
  • a symlink named chart.mmd refused, target intact;
  • a folder holding only owned files (top level and tlc/) passes the check and stops at the next step (the missing mapping).

Plus two from the follow-up: unrecorded TLC files and invalid or missing records are refused (runs without TLC), and TLC reruns replace recorded files, drop old rule files and keep unrecorded ones (needs TLC).

Compatibility cost: an output folder written before this change has no tlc/.written.json, so a rerun into it with a non-empty tlc/ is refused and needs a fresh --out folder. The help text says so.

Rebased onto main after #791 (the Pele renderer port), which kept the pre-fix foreign(); the fix applies unchanged. #791 also moved a comparing run's TLC files into tlc/fixed/ and tlc/against/, so the record now lists every regular file under tlc/ by relative path and the check walks the same way; a file the record does not name is reported by its full path (tlc/fixed/Pay-backup.cfg), with a fixture for it.

Fails on main on the first fixture. node --test on the file: 60 pass, TLC-dependent cases skip here as before. generate-pstack-harness-trees.mjs --check is up to date.

@kam-hak

kam-hak commented Oct 23, 2026

Copy link
Copy Markdown

I reproduced a remaining deletion in this PR with real TLC. OWN_TLC accepts Upload-backup.cfg because it ends in .cfg.

From the repository root, with TLA2TOOLS set:

work=$(mktemp -d)
cp pstack/skills/statechart/scripts/test/upload.mmd "$work/upload.mmd"
node pstack/skills/statechart/scripts/statechart.mjs from-chart "$work/upload.mmd" --out "$work/chart"
printf 'keep this backup\n' > "$work/chart/tlc/Upload-backup.cfg"
node pstack/skills/statechart/scripts/statechart.mjs from-chart "$work/upload.mmd" --out "$work/chart"
test -f "$work/chart/tlc/Upload-backup.cfg"

Both runs exit 0. The final check fails because the second run deleted the backup. Unrelated .out and .dot files have the same problem.

I opened a follow-up against your branch with an exact filename record and regression tests. With that change, normal reruns pass. The backup case exits 2 and leaves every output file unchanged.

The compatibility cost is that existing nonempty tlc/ folders have no record. Those runs need a fresh --out folder.

@deanrie

deanrie commented Oct 24, 2026 •

Copy link
Copy Markdown
Author

Hey @kam-hak, you are right, and the repro is exactly the hole: OWN_TLC matched by extension, so any *.cfg a user dropped into tlc/ counted as ours and went with the folder. An exact record of what the run wrote is the only thing that can tell Upload.cfg from Upload-backup.cfg, so I took your branch as is: deanrie#1 is fast-forwarded into this PR with both commits under your name (494ae7d, 726c902), and the description now covers the record, the two new tests and the compatibility cost for folders written before the record existed.

Checked on my side: inWorkFolder always creates work/tlc, so finish() can write the record on every path. Correction to what I first wrote here: I had checked the merge against a stale local main; #791 (the Pele renderer port) had already rewritten this file the evening before, so the branch was in conflict when I posted. It is now rebased onto that main, which still carries the pre-fix foreign(). #791 also moved a comparing run's TLC files into tlc/fixed/ and tlc/against/, so your record and the check are now recursive (fourth commit, mine): the record lists every regular file under tlc/ by relative path, and an unrecorded file is reported by its full path, tlc/fixed/Pay-backup.cfg, with a fixture for it. 60 pass without TLC, the harness check is up to date, and the PR is mergeable again.

One thing I did not change and want to say out loud: a record names files that are replaceable, so a user who hand-edits Upload.cfg and reruns loses the edit, same as before. That matches the original contract (the folder belongs to the run), but if you think edited owned files deserve the same refusal, that is a separate PR rather than this one.

deanrie and others added 4 commits October 24, 2026 12:43
…hind an owned name

foreign() matched the OWN regex against immediate entry names only, so a
user's tlc/notes.md, a folder named chart.mmd, or a folder named .DS_Store
passed the check and publish() removed them with the previous output
folder. The preservation contract from cursor#741 is that a run refuses files
it did not write.

Only a regular file under an owned name is the run's own. tlc/ is owned
as a folder whose entries are the files this script writes there
(*.cfg, *.out, *.dot, regions-proof.mmd, regions-all.mmd; TLC's metadir
is in the system temp folder), checked one level down. A folder or link
under an owned name is foreign and is reported by its path.

Test covers tlc/notes.md, a folder named chart.mmd, a folder named
.DS_Store, a symlink named chart.mmd (refused, target untouched), and a
folder of only owned files passing to the next step. Fails on main.
Harness tree regenerated.

Fixes cursor#773.
… top of tlc/

cursor#791 moved a comparing run's TLC files into tlc/fixed/ or tlc/against/. A
record of the top-level names alone would list the folder, and the next
run would then report the folder as foreign and refuse. The record now
holds every regular file under tlc/ as a relative path, and the check
walks the same way: a folder is the run's own only while it holds
something recorded, and a file inside it is reported by its full path
(tlc/fixed/Pay-backup.cfg).
@deanrie
deanrie force-pushed the pstack/statechart-foreign-recursive branch from 494ae7d to b4f0c6e Compare October 24, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pstack statechart: output replacement deletes foreign nested files

2 participants