unsloth/scripts/sync_allow_scripts_pins.py
Daniel Han e87d64c3bb
Let the formatter fixed-point guard start on Windows (#11145)
* Stop three installer tests from inheriting and leaking uv resolver policy

Six tests in tests/studio/install fail on main and have since the commits that
introduced them. None of them indicts product code.

test_setup_fast_path_guard.py assigned os.environ["UV_OFFLINE"] directly inside
a 20-iteration loop with no teardown, so it left UV_OFFLINE=2 set process wide.
That file sorts before test_woa_wheelhouse_guard_behaviour.py, whose _run passed
no env= to pwsh, so Test-WoaResolveReachesPyPI saw UV_OFFLINE=2, took the early
return at install.ps1:765 and answered False. That accounts for the failure set
exactly, including why env9 (UV_OFFLINE=0) is the one True row that survives: it
is the only one that overwrites the leaked value. The same leak was also silently
skipping test_transformers_tokenizers_pair.py, which uv rejects outright with
"invalid value 2: expected a boolish value".

Fixed at both ends. The writer uses monkeypatch.setenv. The reader builds an
explicit env with the uv and pip policy variables removed, matching the idiom
test_woa_torch_index_persistence.py already uses, and runs under a fixture that
asserts no uv.toml or [tool.uv] table sits anywhere above its working directory,
since Get-WoaUvConfigIndexPolicy walks to the filesystem root.

test_the_pr_only_added_new_arms_to_the_setup_status_blocks diffed the installers
against a fixed 2026-09-09 SHA and asserted no removed line matched "step " or
"-match". On a PR branch that diff is the PR; on main it is every installer
change since, so it indicts whichever unrelated commit last touched such a line.
It was never satisfiable on main and has been red since it merged, unnoticed
because actions/checkout defaults to fetch-depth 1 and requires_merge_base skips
it in CI. Restated as live invariants on the shipped text: exactly two keep arms
per script, each guarded by its own shell's construct, the three pre-existing
arms present verbatim.

tests/studio/install: 6 failed, 6125 passed, 33 skipped on cf335fa47 before;
6132 passed, 32 skipped after. Each assertion was checked against a real defect
in both directions, so none of them passes merely because the environment is
now scrubbed.

* Repair the six CI checks that are red on main itself

Every one of these reproduces on origin/main with no pull request involved, so
they were failing on every open PR at once and reading like each PR's own fault.
Grouped by root cause rather than by check name: three commits between #11118 and
#10282 account for all six.

b535eca2 (#10282, Windows on ARM native CUDA) -- four failures

  tests/test_windows_amd_gpu_scan_fallback.py, two tests. install.ps1's handoff
  block grew four UNSLOTH_WOA_* saves and restores them from a TABLE walked with
  Set-Item "Env:$(...)", not from four written-out `$env:NAME = $previousX` arms.
  The installer is right; this file's reader knew only the written-out spelling,
  so it read four correct restores as absent. Worse than the noise: a table that
  really HAD gone missing would have been reported identically, so the check could
  not tell a correct block from a broken one. The reader now understands both
  spellings, and a table only counts when something walks it. The four names join
  _CALLER_ENV, so the runtime cases below actually exercise their restore instead
  of the file merely claiming to cover them -- dropping one row from the table, or
  restoring every row from row 0's save, now fails at runtime rather than passing.

  tests/studio/test_cache_budget_discipline.py. The guard flagged woa-wheelhouse's
  vcpkg cache as saving on a pull request ref. It cannot: that workflow is
  workflow_dispatch only, holds signing credentials and is deliberately unreachable
  from a pull request. The guard was reading `if:` without ever reading `on:`, so
  it applied a PR-ref rule to a workflow no PR can reach. It now reads the trigger
  block, the exemption is named with its reason rather than silently skipped, and a
  second test re-derives that claim from `on:` -- so the day woa-wheelhouse gains a
  pull_request trigger it comes straight back under the rule. An ungated cache save
  in a workflow a PR can reach still fails exactly as before.

  tests/studio/test_pwsh_calls_use_the_shared_runner.py. #10282 added four files
  under tests/studio/install that start PowerShell through subprocess directly,
  which opts them back into the shared $XDG_CACHE_HOME/powershell startup-cache
  race (~1 startup in 500 dies before it reaches the script). Routed through
  run_pwsh, which is the one-word change the runner was written for.

9b1f9317 (#11117, localize the prompt queue) -- two failures

  tests/studio/studiobench/.../test_studiobench_queued_idle_live.py. The selftest
  pinned the composer's label as the English literal
  `followUpBehavior === "queue" ? "Queue message" : "Steer response"`, and #11117
  correctly replaced it with the two translation keys. A second assertion, on the
  queue surface's hardcoded English aria-label, was stale for the same reason and
  was hidden behind the first. Both now read the branch and the shipped en catalog,
  which is strictly stronger than the literals were: it also checks that dom.js's
  hardcoded `[aria-label^="Prompt queue,"]` selector still agrees with the catalog,
  which is the coupling a localization pass could have broken in silence.

  tests/studio/test_playwright_suites_run_in_ci.py. #11117 added
  playwright_queue_localization.py and wired it into nothing. It is a gate, not a
  measurement harness, so it is wired into the composer-compatibility engine loop
  beside the sibling suite that drives the same fixture -- not added to NOT_IN_CI.
  Verified green first on chromium 153, firefox 155 and webkit 26.6, so this adds
  coverage rather than a new red.

cf335fa4 (#11118) and 9b1f9317 (#11117) -- one failure

  tests/test_formatter_fixed_point.py named three files that are not a fixed point
  of the ruff-format-with-kwargs hook, which fails pre-commit.ci on the next PR to
  edit any of them. Formatted with the pinned ruff 0.6.9.

None of this changes shipped behaviour. studio/backend/main.py is in the diff for
formatting only, and install.ps1 is untouched -- it was correct throughout.

Before / after, measured the same way both times:

  workflow guard suites   2 failed, 1961 passed, 44 skipped
                       -> 0 failed, 1976 passed, 44 skipped
  studiobench selftests   1 failed, 1423 passed, 145 skipped
                       -> 0 failed, 1424 passed, 145 skipped

* Scope the cache-save guard by trigger, not by an excused filename

Follow-up on review of the previous commit. The first version skipped
woa-wheelhouse.yml by name, from a dict carrying the reason. That works and it
rots: skipping a whole FILE blinds the guard to every other step in it, so a
`setup-python` implicit cache added to that workflow later would have been
invisible, and the excuse would keep applying after the reason for it had gone.
It is the same shape as a PINNED_BY_DESIGN keyed on filename.

The predicate was already there. Now it is the only thing doing the work: a
workflow is scanned if and only if `_pull_request_reachable` says a pull request
can run it, and there is no list. The day a workflow gains a `pull_request` or
`pull_request_target` trigger, every cache save in it is indicted on that same
commit with nobody having to remember an entry exists.

Composite actions stay indicted unconditionally. An action has no triggers of its
own, and one `uses:`d from a pull_request job saves on the PR's ref exactly as an
inline step would.

Mutation-proved, each applied then restored from a file copy:

  ungated actions/cache save injected into lint-ci.yml            RED
  ... into consolidated-tests-ci.yml                              RED
  ... into studio-frontend-ci.yml                                 RED
  woa-wheelhouse.yml given pull_request_target, save unchanged    RED
  woa-wheelhouse.yml given pull_request, save unchanged           RED
  woa-wheelhouse.yml given pull_request AND a second offender     RED, naming BOTH

The last is the one the filename exemption could not do. It reports

    woa-wheelhouse.yml:build-pyarrow: setup-python implicit post-step save
    woa-wheelhouse.yml:build-pyarrow: Cache vcpkg packages

where the excused-filename version would have named neither.

Workflow guard suites: 1975 passed, 44 skipped, 0 failed.

* Let the formatter fixed-point guard start on Windows

The guard passed every tracked file to one subprocess.run. That is 2656 paths
and, under a Windows pytest tmp_path, 324,905 characters against CreateProcess's
32,767 -- 9.9x over -- so on Windows the call died with

    [WinError 206] The filename or extension is too long

before ruff opened a single file. It has never been caught because every job
that schedules this file is ubuntu-24.04, where execve's ARG_MAX is about 2 MB
and the same argv fits easily. So this is latent, not a current red: nothing
fails today, and the guard simply cannot be scheduled anywhere else.

Batched, and safely: the hook is per-file, so N calls over disjoint batches do
exactly what one call over the union does.

A LENGTH budget, not a file count. A count needs re-tuning whenever the tracked
set or the path depth moves and silently loses its margin in between -- the
first version of this used 200 files, which measured 26,687 characters, only
1.2x under the cap, so a fifth more files would have put it straight back over.
Accumulating by length is self-adjusting: 42 batches, widest 7,999 characters,
4.1x headroom, and no number to keep in step with the repo.

The protection is a test, not a Windows runner. A permanent Windows leg would
tax every future PR through an org queue that has been several hundred deep all
day, to protect one guard. test_the_formatter_invocation_fits_in_a_windows_
command_line computes the real command lines from the real file list and asserts
the widest stays under the documented Windows cap. It is pure Python, runs in
the Linux job that already exists, and fails fast and everywhere.

It drives formatter_argvs, the same builder the guard runs, rather than checking
the budget constant -- a test that only read the constant would stay green while
the call site did something else.

Mutation-proved, each applied then restored from a file copy:

  builder reverted to one call with everything   RED "the widest of the 1 ...
                                                  builds a 324977-character
                                                  command line"
  budget raised to 40000, past the cap           RED, names the 39998 it built
  batching drops its final partial batch         RED "batching lost or
                                                  reordered files"
  cap raised until batching stops mattering      RED "batching is no longer
                                                  load-bearing and this test no
                                                  longer proves anything"

The last is the non-vacuity guard: if the tracked set ever shrinks enough that
one call fits, this says so instead of passing on a check that measures nothing.

tests/test_formatter_fixed_point.py: 11 passed, was 10.

* Read the locale entry, not the line it starts on

studio/frontend/tests/pasted-text-attachment.test.ts "every locale keeps the
shortcut in the threshold description" fails on origin/main, which takes down
"Frontend build + bundle sanity" and "Frontend unit tests (Windows)" on every PR
whose merge picks up main. Reported by the lane that hit it on #11070; it is
main's test rather than that PR's, so it belongs here.

The test did source.indexOf("pastedTextThresholdDescription:"), sliced to the
next newline, and asserted "{shortcut}" was on that slice. That asserts about the
formatter as much as about the translation: a value Prettier wraps onto the
FOLLOWING line leaves the slice holding only the key, and the placeholder is
invisible while being right there.

Product is correct. All twelve locales carry {shortcut} -- checked by parsing the
value out of each file rather than by reading the assertion's own slice.

It is also worse than one locale. ELEVEN of the twelve are wrapped that way on
main today; only en.ts is not. The test was passing on readdir order alone, and
fixing ar.ts by hand would have handed the same failure to de.ts, then es.ts, and
so on down the directory.

So it now imports the locale module and asserts on the resolved string, which is
what the app reads and cannot be moved by layout. The key is found by walking the
tree rather than by a fixed path, so reorganising the message tree does not
silently drop the check either, and `values.length == 1` keeps it from passing
vacuously if the key disappears.

Checked the other indexOf-based source assertions in studio/frontend/tests while
here: they bound their slices with an explicit end marker ("];", the next symbol
name), not with the next newline, so none share this failure mode.

Mutation-proved:

  a locale drops {shortcut}          RED "ar.ts keeps {shortcut}"
  a locale loses the key entirely    RED "ja.ts carries the description
                                      exactly once"
  a value wrapped onto the next line GREEN here, and the old line-scoped
                                      assertion on the same tree sees only
                                      'pastedTextThresholdDescription:' and fails

The third is the regression itself, and it needs no synthetic edit: eleven
locales on main are already in that state.

studio/frontend: 34 pass, 0 fail (was 33 pass, 1 fail).

* Run the queue localization suite for the files it actually reads

Wiring the driver into the composer job left it unreachable by the change it
guards. The workflow filters on tests/studio/*composer* and
tests/studio/*prompt_queue*, and playwright_queue_localization.py carries
neither substring, so a pull request that edits only the driver never runs it.
Nor does one that edits only a catalog: the driver calls setLocale through
src/i18n/index.ts and asserts five hard-coded Japanese strings, and no pattern
reached src/i18n at all. A translation-only edit to ja.ts is the exact change
this suite exists to catch.

tests/studio/test_playwright_suites_run_in_ci.py cannot see this by design; its
own docstring says trigger paths say when CI runs, not what it runs.

Add the two patterns. Checked against GitHub's filter semantics with the
controls in both directions: the driver, each locale catalog and the i18n
loader now trigger the job, thread.tsx and playwright_prompt_queue_actions.py
still do, and README.md and unsloth/models/llama.py still do not.

Also close a hole in _pull_request_reachable, whose docstring promises that
anything unrecognised counts as reachable while the code fell through to
exempt. merge_group runs on its own refs/heads/gh-readonly-queue ref, so a
cache saved there is exactly the pull-request-scoped entry this rule forbids,
and the guard would have said nothing. Name the three events that really are
default-branch-only and indict the rest. No current workflow changes verdict.

* Scrub the resolver policy variables case-folded

The scrub list carries one mixed-case name, ProgramData, and it is the one that
would survive a literal match on the only platform it matters on. CPython
upper-cases every environment key on nt (os.py, _createenviron: encodekey does
encode(key).upper(), under the comment Where Env Var Names Must Be UPPERCASE),
so on Windows os.environ hands the comprehension PROGRAMDATA, it compares
unequal to ProgramData, and the child pwsh keeps reading
%PROGRAMDATA%\uv\uv.toml.

That file is uv's system-level config and install.ps1 reads it:
Get-WoaUvConfigIndexPolicy joins it at install.ps1:737, and a NoIndex or a
non-PyPI DefaultIndex there flips Test-WoaResolveReachesPyPI to False. So a row
asserting True becomes machine-dependent on exactly the boxes the installer
ships to, which is the failure this scrub exists to remove.

Fold the comparison, and state the requirement in a test that runs everywhere:
a Linux session has no ProgramData to leak, so a scrub that only matches
literally passes this suite green on CI and quietly stops working on Windows.
The parametrisation feeds the three spellings an environment block can carry.

* Correct the argv length this guard is sized from

Three places state the number and two of them were stale: the constant's own
comment says ~325,000 characters and 9.9x the cap, while the test docstring and
the batching comment said 378,000 and 11.5x. Measured with the file's own
Windows prefix over the real tracked list: 2656 files, 324,905 characters,
9.9x. The whole argument for the constant is quantitative, so a reader checking
it should not land on a figure that is 16 percent out.

* Stop the scripts that rewrite tracked files turning them CRLF on Windows (#11158)

* Stop the scripts that rewrite tracked files turning them CRLF on Windows

Found by the staging Windows run on #11145. With the argv fix in place the
formatter guard finally STARTED on windows-latest, and then reported ~1300 of
~2650 tracked files as drifted. None of them had drifted.

.gitattributes opens with `*.py text eol=lf`, so tracked Python here is LF on
disk and meant to stay that way. Three scripts rewrite tracked files through the
same temp-file-then-os.replace shape and all three opened the temp file in text
mode with the DEFAULT newline, which translates every "\n" to os.linesep:

  scripts/enforce_kwargs_spacing.py   every tracked .py the hook touches
  scripts/stamp_studio_release.py     studio/backend/utils/_studio_release_build.py
  scripts/scan_packages.py            studio/backend/requirements/*.txt, under --fix

On Linux os.linesep is "\n" and the round trip is a no-op, which is why this
survived. On Windows it rewrites the file to CRLF. For enforce_kwargs_spacing
that is every file a contributor edits: the read side is tokenize.open, which
normalises CRLF to LF in memory, so the write alone decides the ending. The
symptom is a whole-file diff on files the contributor never touched, produced by
running this project's own pre-commit hook. Contributor-facing, not test-only.

newline = "\n" rather than newline = "". Both are correct and both land LF here,
so this is a readability choice: "\n" states the ending .gitattributes mandates
rather than "do not translate", and it stays right even if the read side stops
normalising. It is also already the spelling used at
unsloth_cli/commands/studio.py:788.

scan_packages.py's write had no `encoding` either, so it took the locale codec
and a non-ASCII comment round-tripped differently between runners. Fixed on both
halves of that call. studio/backend has a guard for exactly the encoding case,
studio/backend/tests/test_text_io_encoding.py, but it scans BACKEND_ROOT only,
so scripts/ was never covered by it.

Surveyed rather than taking the three reported hits on trust: every os.fdopen
text-mode write in the repo. The rest are runtime temp files and reports whose
on-disk ending is nobody's contract, so they are left alone -- the rule applied
here is "rewrites a TRACKED file in place".

tests/test_scripts_write_lf.py is two halves, and the split is the point:

  * the behavioural tests put a CRLF file in and assert LF bytes out, through the
    real write path. On Windows they exercise the translation for real. On LINUX
    THEY CANNOT FAIL if the fix is reverted, because os.linesep is already "\n".
  * test_every_in_place_rewriter_names_its_newline reads the call sites and fails
    on any platform the moment a `newline =` or `encoding =` goes missing. That
    is the half that protects the Linux job that actually runs, and it is the
    same principle as #11145's argv test driving formatter_argvs rather than the
    budget constant: assert the property where the platform cannot show it.

Mutation-proved, all four RED ON LINUX, each restored from a file copy:

  newline reverted on enforce_kwargs_spacing   RED, names file:line and the fix
  newline reverted on stamp_studio_release     RED
  newline + encoding reverted on scan_packages RED
  .gitattributes stops pinning *.py to LF      RED "this whole file needs
                                                re-reading" (the premise check)

tests/test_scripts_write_lf.py 7 passed; with test_scan_packages.py and
test_formatter_fixed_point.py, 154 passed. studio/backend's own encoding guard
still 522 passed.

* Stop a leaked torch import error deciding another test's verdict

studio/backend/tests/test_torch_cpu_build_on_nvidia_host.py's nameless Intel
row went red on one Backend CI shard and passed in every serial run. The cause
is order, not the card.

hw._has_torch() writes the module global TORCH_IMPORT_ERROR as a side effect,
and _torch_reports_an_xpu_runtime, _torch_reports_a_hip_runtime and
classify_torch_build all branch on it: set, they read the wheel off disk and
never look at sys.modules, so the fake 2.9.0+xpu torch the test installs is
invisible, xpu_expected comes back false and the card is dropped. Four tests
watch an import fail and none of them put the global back, and the rest shard
is the only one holding both files.

Reproduced without xdist at all: run test_a_broken_torch_counts_as_no_torch and
then the Intel row in one process and the failure is byte-identical to CI, same
line. A leak detector over the serial shard names all four writers before this
change and none after.

Two independent defences. _shared_setup_1 pins TORCH_IMPORT_ERROR to None, so
the test states the precondition it depends on rather than inheriting whatever
ran before it in the worker; each of the four leaks is fixed at source with a
snapshot restore, since nothing there owns the value. A new row proves the pin
is load-bearing by setting the global to exactly what a sibling leaves and
showing the verdict invert, with its on-disk arm pinned so it does not turn on
what torch the host happens to have.

Also widens this PR's own LF survey, which looked only at os.fdopen and so
missed two writers with the identical default-newline defect.
scripts/sync_allow_scripts_pins.py rewrites studio/frontend/package.json, which
.gitattributes pins to eol=lf, and runs as a pre-commit hook with --fix, so a
Windows contributor bumping a frontend dependency got a whole-file CRLF rewrite
from a two-line change. scan_packages._write_baseline is the same shape. The
guard now reads Path.write_text too, or a rewriter could drop out of it by
swapping one call for the other.

* Re-pin the three zoo entries the scan reopened

pip scan-packages :: hf-stack went red on three HIGH findings, all in
unsloth-zoo's own metaprogramming: compiler.py, saving_utils.py and
mlx/loader.py under "Advanced obfuscation (marshal/compile/zlib) +
exec/eval". Not this branch's doing -- zoo's main moved.

Two of the three are file_sha256 pinned, so they reopened because the FILE
changed, not because the matched lines did. That pin exists for danger
sitting outside the matched lines, so re-pinning is a review of the file
rather than of the evidence, and it was done as one: neither compiler.py
nor mlx/loader.py contains any network primitive (urlopen, requests, raw
sockets, an http URL that is not a licence header), any payload decoder
(base64, marshal, pickle, zlib) or os.system at the current content. The
scanner agrees -- neither file appears under any of the CRITICAL checks,
which is where secrets-plus-network lives. compiler.py's lone subprocess
import is never called. What is flagged remains what was flagged before:
__import__ with a computed module name, chr(92) in the banner art, and
mlx/loader.py's mx.eval, which evaluates an MLX array and is not Python's
eval at all.

saving_utils.py is unpinned and its evidence hash moved on its own terms:
__import__("transformers", fromlist=[model_class_name]) and the exec of a
"from transformers.modeling_utils import (...)" line.

Appended rather than replaced, because duplicate keys union their pins:
this widens the allowed digests for those two files by exactly one each and
reopens nothing. Scan now exits 0 with 0 HIGH.
2026-09-16 20:02:06 -07:00

149 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Keep `allowScripts` pins in studio/frontend/package.json in sync with
package-lock.json.
`npm approve-scripts` writes version-pinned entries ("pkg@1.2.3": true).
A dependency bump strands the pin, so the approval (or denial) silently
stops matching and the package's install scripts fall back to
"unreviewed". This tool re-pins existing entries to the versions the
lockfile actually resolves; it never adds or removes entries, so
approving a brand-new script-bearing package stays a human decision.
Usage:
python scripts/sync_allow_scripts_pins.py --check # CI: exit 1 on drift
python scripts/sync_allow_scripts_pins.py --fix # rewrite package.json
Pinned keys follow npm's allowScripts grammar: "name@1.2.3" or
"name@1.2.3 || 1.2.4". Bare names (no version) match every version and
are left alone. Entries whose range is not an exact-version disjunction
(wildcards, tags) are left alone too.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DIR = REPO_ROOT / "studio" / "frontend"
EXACT_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$")
def split_spec(key: str) -> tuple[str, str | None]:
"""'@scope/name@1.2.3' -> ('@scope/name', '1.2.3'); bare names -> (key, None)."""
if key.startswith("@"):
rest = key[1:]
if "@" not in rest:
return key, None
name, rng = rest.split("@", 1)
return "@" + name, rng
if "@" not in key:
return key, None
name, rng = key.split("@", 1)
return name, rng
def is_exact_disjunction(rng: str) -> bool:
parts = [p.strip() for p in rng.split("||")]
return all(EXACT_VERSION_RE.match(p) for p in parts) and bool(parts)
def version_sort_key(version: str) -> tuple:
release = version.split("-", 1)[0].split("+", 1)[0]
return tuple(int(x) for x in release.split(".")), version
def script_versions_from_lock(lock: dict) -> dict[str, list[str]]:
"""Map package name -> sorted versions that carry install scripts."""
out: dict[str, set[str]] = {}
for path, meta in (lock.get("packages") or {}).items():
if not path or not meta.get("hasInstallScript"):
continue
name = path.rsplit("node_modules/", 1)[-1]
version = meta.get("version")
if name and version:
out.setdefault(name, set()).add(version)
return {n: sorted(vs, key = version_sort_key) for n, vs in out.items()}
def desired_key(name: str, versions: list[str]) -> str:
return f"{name}@{' || '.join(versions)}"
def compute_renames(policy: dict, lock_versions: dict[str, list[str]]) -> dict[str, str]:
renames: dict[str, str] = {}
for key in policy:
name, rng = split_spec(key)
if rng is None or not is_exact_disjunction(rng):
continue # bare name or non-exact spec: matches by name, never stale
versions = lock_versions.get(name)
if not versions:
continue # package gone or script-free now: stale pin is inert
want = desired_key(name, versions)
if key != want:
renames[key] = want
return renames
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description = __doc__)
mode = ap.add_mutually_exclusive_group(required = True)
mode.add_argument("--check", action = "store_true", help = "exit 1 if pins are stale")
mode.add_argument("--fix", action = "store_true", help = "rewrite package.json in place")
ap.add_argument(
"--dir",
type = Path,
default = DEFAULT_DIR,
help = "directory holding package.json + package-lock.json",
)
args = ap.parse_args(argv)
pkg_path = args.dir / "package.json"
lock_path = args.dir / "package-lock.json"
if not pkg_path.exists() or not lock_path.exists():
print(f"sync-allow-scripts: nothing to do ({args.dir} has no package.json + lockfile)")
return 0
pkg = json.loads(pkg_path.read_text(encoding = "utf-8"))
policy = pkg.get("allowScripts")
if not isinstance(policy, dict) or not policy:
print("sync-allow-scripts: no allowScripts policy in package.json, nothing to do")
return 0
lock = json.loads(lock_path.read_text(encoding = "utf-8"))
renames = compute_renames(policy, script_versions_from_lock(lock))
if not renames:
print(f"sync-allow-scripts: {len(policy)} allowScripts entries in sync with the lockfile")
return 0
for old, new in renames.items():
print(f' stale pin: "{old}" -> "{new}"')
if args.check:
print(
"sync-allow-scripts: pins are stale; run "
"`python scripts/sync_allow_scripts_pins.py --fix` and commit the result"
)
return 1
pkg["allowScripts"] = {renames.get(k, k): v for k, v in policy.items()}
# newline = "\n", not the default None, which translates every "\n" to os.linesep. This is a
# pre-commit hook running --fix on studio/frontend/package.json, and .gitattributes pins that
# file to LF (`studio/frontend/** text=auto eol=lf`), so on Windows the default turned a
# two-line pin bump into a whole-file CRLF rewrite. open() rather than write_text(newline =):
# write_text only grew `newline` in 3.10 and pyproject still says requires-python >= 3.9.
with open(pkg_path, "w", encoding = "utf-8", newline = "\n") as handle:
handle.write(json.dumps(pkg, indent = 2, ensure_ascii = False) + "\n")
print(
f"sync-allow-scripts: re-pinned {len(renames)} entr{'y' if len(renames) == 1 else 'ies'} in {pkg_path}"
)
return 0
if __name__ == "__main__":
sys.exit(main())