zero-slop 2.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/SKILL.md +803 -0
  4. package/data/corpus/community-register/README.md +14 -0
  5. package/data/corpus/community-register/judgment/communicative-drift.txt +1 -0
  6. package/data/corpus/community-register/judgment/reflexive-agreement.txt +1 -0
  7. package/data/corpus/community-register/judgment/rhetorical-scale-mismatch.txt +1 -0
  8. package/data/corpus/must-not-flag/README.md +49 -0
  9. package/data/corpus/must-not-flag/esl-engineer-email.txt +7 -0
  10. package/data/corpus/must-not-flag/exec-memo.txt +9 -0
  11. package/data/corpus/must-not-flag/federalist.txt +5 -0
  12. package/data/corpus/must-not-flag/gettysburg.txt +1 -0
  13. package/data/corpus/must-not-flag/grant-abstract.txt +5 -0
  14. package/data/corpus/must-not-flag/ml-methods.txt +1 -0
  15. package/data/corpus/must-not-flag/personal-essay.txt +1 -0
  16. package/data/corpus/must-not-flag/press-release.txt +7 -0
  17. package/data/corpus/must-not-flag/recipe.txt +11 -0
  18. package/data/corpus/must-not-flag/sre-runbook.txt +1 -0
  19. package/data/corpus/must-not-flag/technical-postmortem.txt +1 -0
  20. package/data/corpus/must-not-flag/terse-engineer-note.txt +1 -0
  21. package/data/corpus/must-not-flag-shape/README.md +22 -0
  22. package/data/corpus/must-not-flag-shape/aphorism-list.txt +9 -0
  23. package/data/corpus/must-not-flag-shape/changelog.txt +13 -0
  24. package/data/corpus/must-not-flag-shape/dialogue.txt +19 -0
  25. package/data/corpus/must-not-flag-shape/lyrics.txt +15 -0
  26. package/data/corpus/must-not-flag-shape/poem.txt +8 -0
  27. package/data/corpus/must-not-flag-shape/sms-transcript.txt +15 -0
  28. package/data/corpus/performed-register/README.md +38 -0
  29. package/data/corpus/performed-register/judgment/detection-fragile.txt +1 -0
  30. package/data/corpus/performed-register/judgment/draft-cheap-signal.txt +1 -0
  31. package/data/corpus/performed-register/judgment/ear-chiasmus.txt +1 -0
  32. package/data/corpus/performed-register/judgment/fight-against-tagline.txt +1 -0
  33. package/data/corpus/performed-register/judgment/fine-beat.txt +1 -0
  34. package/data/corpus/performed-register/judgment/glow-finishing.txt +1 -0
  35. package/data/corpus/performed-register/judgment/jargon-compression.txt +1 -0
  36. package/data/corpus/performed-register/judgment/not-perfect-honest.txt +1 -0
  37. package/data/corpus/performed-register/judgment/punctuation-convict.txt +1 -0
  38. package/data/corpus/performed-register/judgment/recipe-ingredients.txt +1 -0
  39. package/data/corpus/performed-register/judgment/replay-lands.txt +1 -0
  40. package/data/corpus/performed-register/judgment/rhymes-with.txt +1 -0
  41. package/data/corpus/performed-register/judgment/rhythm-prints.txt +1 -0
  42. package/data/corpus/performed-register/judgment/same-leak.txt +1 -0
  43. package/data/corpus/performed-register/judgment/tools-work.txt +1 -0
  44. package/data/corpus/performed-register/judgment/tuning-gradients.txt +1 -0
  45. package/data/corpus/performed-register/judgment/verdict-arithmetic.txt +1 -0
  46. package/data/corpus/performed-register/judgment/whole-product.txt +1 -0
  47. package/data/corpus/performed-register/judgment/words-cheap.txt +1 -0
  48. package/data/corpus/performed-register/mechanical/argue-with-artifact.txt +1 -0
  49. package/data/corpus/performed-register/mechanical/bill-lands.txt +1 -0
  50. package/data/corpus/performed-register/mechanical/billed-to-reader.txt +1 -0
  51. package/data/corpus/performed-register/mechanical/economics-brutal.txt +1 -0
  52. package/data/corpus/performed-register/mechanical/has-receipts.txt +1 -0
  53. package/data/corpus/performed-register/mechanical/hired-adversary.txt +1 -0
  54. package/data/corpus/performed-register/mechanical/hyperbole-universal.txt +1 -0
  55. package/data/corpus/performed-register/mechanical/metaphor-sender-name.txt +1 -0
  56. package/data/corpus/performed-register/mechanical/minding-own-business.txt +1 -0
  57. package/data/corpus/performed-register/mechanical/on-the-tin.txt +1 -0
  58. package/data/corpus/performed-register/mechanical/opens-the-hood.txt +1 -0
  59. package/data/corpus/performed-register/mechanical/turns-out-payoff.txt +1 -0
  60. package/data/corpus/performed-register/mechanical/vibe-check.txt +1 -0
  61. package/data/corpus/performed-register/mechanical/vibe-isnt-a-vibe.txt +1 -0
  62. package/data/corpus/performed-register/mechanical/vibe-number-attached.txt +1 -0
  63. package/data/corpus/performed-register/mechanical/where-slop-lives.txt +1 -0
  64. package/data/learned-log.md +339 -0
  65. package/data/learned.json +128 -0
  66. package/data/patterns.json +2298 -0
  67. package/package.json +54 -0
  68. package/references/copy-desk.md +87 -0
  69. package/references/evidence.md +382 -0
  70. package/references/overcorrection.md +77 -0
  71. package/references/platforms.md +102 -0
  72. package/references/readalong.md +118 -0
  73. package/references/rewrite-moves.md +177 -0
  74. package/references/tells.md +190 -0
  75. package/scripts/calibrate.py +274 -0
  76. package/scripts/learn.py +1588 -0
  77. package/scripts/predictability.py +227 -0
  78. package/scripts/rerank.py +150 -0
  79. package/scripts/safeio.py +146 -0
  80. package/scripts/slopscore.py +1352 -0
  81. package/scripts/version_check.py +104 -0
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ """predictability — see how easily the AI assistant can guess the original wording.
3
+
4
+ The strongest signal that a machine wrote something is that a machine finds it
5
+ predictable: the words sit where a language model would have put them. Detectors
6
+ measure this with token log-probabilities, but Zero Slop ships no model and Claude's
7
+ API exposes no logprobs, so this computes the same thing a different way — a cloze
8
+ probe the harness's own model answers.
9
+
10
+ It masks a spread of content words, hands the blanks (context only, answer withheld)
11
+ to the model executing the skill, and measures how often the model's guesses hit the
12
+ word the author actually used. High agreement means the text is what the model would
13
+ have written — machine-predictable. Human writing picks the less-likely word more
14
+ often, and the score falls. This works with any harness model (Claude, GPT, …) because
15
+ it needs only generation, never logprobs, and Zero Slop supplies no model of its own.
16
+
17
+ The protocol is three steps, so any agent can drive it:
18
+
19
+ python3 scripts/predictability.py --probes draft.md > probes.json # 1. blanks
20
+ # 2. the agent fills {id: [top-3 guesses]} from each context, into preds.json
21
+ python3 scripts/predictability.py --score draft.md preds.json # 3. reading
22
+
23
+ Probe selection and scoring are deterministic, so the Python is fully testable. The
24
+ model's three guesses can vary unless the host provides deterministic generation. It is
25
+ a corroborating channel reported beside the surface score, never folded into it — the
26
+ surface score stays traceable to spans, and this says whether a model finds the prose
27
+ predictable.
28
+ """
29
+ import json
30
+ import re
31
+ import sys
32
+ import argparse
33
+ from pathlib import Path
34
+
35
+ # Function words carry no predictability signal (everyone writes "the", "of"), so
36
+ # probes land on content words only.
37
+ STOP = set("""the a an and or but if then else of to in on at by for with from into
38
+ over under again once here there when while as is are was were be been being have has
39
+ had do does did will would can could should may might must not no nor so than too very
40
+ this that these those it its their your our his her they them we you i he she who whom
41
+ which what how why about after before between during through above below up down out off
42
+ only just also even still yet more most less least own same other such both each any all
43
+ some few many much one two three""".split())
44
+
45
+
46
+ def _words(text):
47
+ """(word, start, end) for alphabetic tokens, code and quotes stripped out."""
48
+ # Preserve character offsets while blanking code. Probe contexts slice this
49
+ # cleaned text; shrinking a fenced block made every later offset point into
50
+ # the wrong word in the original document.
51
+ text = re.sub(r"```.*?```", lambda m: " " * len(m.group(0)), text, flags=re.S)
52
+ text = re.sub(r"`[^`]*`", lambda m: " " * len(m.group(0)), text)
53
+ return [(m.group(0), m.start(), m.end()) for m in re.finditer(r"[A-Za-z][A-Za-z'-]+", text)]
54
+
55
+
56
+ def _without_code(text):
57
+ text = re.sub(r"```.*?```", lambda m: " " * len(m.group(0)), text, flags=re.S)
58
+ return re.sub(r"`[^`]*`", lambda m: " " * len(m.group(0)), text)
59
+
60
+
61
+ def _norm(w):
62
+ return re.sub(r"[^a-z]", "", w.lower())
63
+
64
+
65
+ def _morph_roots(word):
66
+ """Conservative English inflection roots; never equate by prefix alone."""
67
+ word = _norm(word)
68
+ roots = {word}
69
+ for suffix in ("ingly", "edly", "ing", "ied", "ed", "es", "s", "ly"):
70
+ if word.endswith(suffix) and len(word) - len(suffix) >= 4:
71
+ stem = word[:-len(suffix)]
72
+ roots.add(stem)
73
+ if suffix in {"ing", "ed", "edly", "ingly"}:
74
+ roots.add(stem + "e")
75
+ if suffix == "ied":
76
+ roots.add(stem + "y")
77
+ return roots
78
+
79
+
80
+ def probes(text, k=12, window=45):
81
+ """Deterministically pick up to k content words to mask.
82
+
83
+ Eligible = alphabetic, >=4 letters, not a stopword, not the document's first word.
84
+ They are taken at an even stride across the eligible list so the probes span the
85
+ whole piece, and the selection is a pure function of the text — same input, same
86
+ probes, which is what makes the score reproducible and the scorer testable.
87
+ """
88
+ if not isinstance(k, int) or isinstance(k, bool) or k < 1:
89
+ raise ValueError("k must be a positive integer")
90
+ if not isinstance(window, int) or isinstance(window, bool) or window < 0:
91
+ raise ValueError("window must be a non-negative integer")
92
+ clean = _without_code(text)
93
+ ws = _words(clean)
94
+ eligible = [i for i, (w, s, e) in enumerate(ws)
95
+ if len(_norm(w)) >= 4 and _norm(w) not in STOP and i > 0]
96
+ if not eligible:
97
+ return []
98
+ k = min(k, len(eligible))
99
+ stride = len(eligible) / k
100
+ picks = [eligible[int(j * stride)] for j in range(k)]
101
+
102
+ out = []
103
+ for pid, wi in enumerate(picks):
104
+ w, s, e = ws[wi]
105
+ before = clean[:s]
106
+ ctx_words = re.findall(r"\S+", before)[-window:]
107
+ context = " ".join(ctx_words) + " ___"
108
+ out.append({"id": pid, "context": context, "answer": _norm(w)})
109
+ return out
110
+
111
+
112
+ def _hit(answer, guesses):
113
+ """A guess counts if it matches the answer up to case and light morphology —
114
+ exact, or a shared 4-letter stem, so "raised"/"raise" and "quickly"/"quick" land."""
115
+ a = _norm(answer)
116
+ answer_roots = _morph_roots(a)
117
+ for g in guesses:
118
+ gg = _norm(g)
119
+ if not gg:
120
+ continue
121
+ if gg == a or answer_roots & _morph_roots(gg):
122
+ return True
123
+ return False
124
+
125
+
126
+ def score(text, predictions, k=12):
127
+ """predictions: {id: [top guesses]}. Returns the predictability reading.
128
+
129
+ predictability = share of masked words the model's top-three guesses recovered,
130
+ 0–100. Higher means more machine-predictable. The descriptive bands are operating
131
+ thresholds, not calibrated probabilities, and the result is never silently folded
132
+ into the surface score.
133
+ """
134
+ if not isinstance(text, str):
135
+ raise ValueError("text must be a string")
136
+ if not isinstance(predictions, dict):
137
+ raise ValueError("predictions must be a JSON object keyed by probe id")
138
+ pr = probes(text, k=k)
139
+ if not pr:
140
+ return {"predictability": None, "hits": 0, "total": 0,
141
+ "reading": "too short to probe", "backend": "harness-model"}
142
+ preds = {}
143
+ for key, guesses in predictions.items():
144
+ try:
145
+ probe_id = int(key)
146
+ except (TypeError, ValueError) as exc:
147
+ raise ValueError(f"invalid probe id: {key!r}") from exc
148
+ if probe_id in preds:
149
+ raise ValueError(f"duplicate probe id after normalization: {key!r}")
150
+ if isinstance(guesses, str):
151
+ guesses = [guesses]
152
+ if (not isinstance(guesses, list) or not guesses or len(guesses) > 20
153
+ or not all(isinstance(g, str) and len(g) <= 200 for g in guesses)):
154
+ raise ValueError(f"predictions for probe {probe_id} must be a string list")
155
+ preds[probe_id] = guesses
156
+ expected = {p["id"] for p in pr}
157
+ if set(preds) != expected:
158
+ missing, extra = sorted(expected - set(preds)), sorted(set(preds) - expected)
159
+ raise ValueError(f"prediction ids do not match probes: missing={missing}, extra={extra}")
160
+ hits = 0
161
+ for p in pr:
162
+ g = preds.get(p["id"], [])
163
+ if _hit(p["answer"], g[:3]):
164
+ hits += 1
165
+ total = len(pr)
166
+ val = round(100 * hits / total, 1)
167
+ if val >= 50:
168
+ reading = "easy to guess — much of the wording follows familiar patterns"
169
+ elif val >= 33:
170
+ reading = "moderately easy to guess"
171
+ else:
172
+ reading = "hard to guess — the wording is less predictable"
173
+ return {"predictability": val, "hits": hits, "total": total,
174
+ "reading": reading, "backend": "harness-model"}
175
+
176
+
177
+ def _selftest():
178
+ """The scaffold is deterministic; prove it without a model, both extremes."""
179
+ text = ("The startup raised a substantial round from investors who believed in the "
180
+ "mission. Revenue climbed steadily through the difficult second quarter, and "
181
+ "the founders remained cautiously optimistic about the coming year ahead.")
182
+ pr = probes(text, k=6)
183
+ assert pr and probes(text, k=6) == pr, "probe selection must be deterministic"
184
+ allright = {p["id"]: [p["answer"]] for p in pr}
185
+ allwrong = {p["id"]: ["xyzzy"] for p in pr}
186
+ hi = score(text, allright, k=6)
187
+ lo = score(text, allwrong, k=6)
188
+ assert hi["predictability"] == 100.0, hi
189
+ assert lo["predictability"] == 0.0, lo
190
+ assert _hit("raised", ["raise"]), "light morphology must match"
191
+ assert not _hit("station", ["statue"]), "shared prefixes are not morphology"
192
+ print(f"predictability selftest OK — {len(pr)} probes, all-right=100.0, all-wrong=0.0")
193
+ return 0
194
+
195
+
196
+ def main(argv=None):
197
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
198
+ mode = ap.add_mutually_exclusive_group(required=True)
199
+ mode.add_argument("--selftest", action="store_true")
200
+ mode.add_argument("--probes", metavar="TEXT_FILE")
201
+ mode.add_argument("--score", nargs=2, metavar=("TEXT_FILE", "PREDICTIONS_JSON"))
202
+ args = ap.parse_args(argv)
203
+ if args.selftest:
204
+ return _selftest()
205
+ if args.probes:
206
+ try:
207
+ text = Path(args.probes).read_text()
208
+ except (OSError, UnicodeDecodeError) as exc:
209
+ ap.error(f"cannot read text: {exc}")
210
+ blanks = [{"id": p["id"], "context": p["context"]} for p in probes(text)]
211
+ print(json.dumps(blanks, indent=1))
212
+ return 0
213
+ if args.score:
214
+ try:
215
+ text = Path(args.score[0]).read_text()
216
+ preds = json.loads(Path(args.score[1]).read_text())
217
+ r = score(text, preds)
218
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
219
+ ap.error(str(exc))
220
+ print(f"How easy the original wording was to guess: {r['predictability']}/100")
221
+ print(f" The model guessed {r['hits']} of {r['total']} hidden words. {r['reading']}.")
222
+ return 0
223
+ return 2
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main())
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """rerank — generate several rewrites, keep the best one, objectively.
3
+
4
+ A single rewrite is one sample from the model. Two or three, written with different
5
+ strategies (strip hard vs. keep the warmth, reorder vs. stay put), give the loop
6
+ something to choose between — and the choice should be made by the meter, not by the
7
+ same taste that wrote them. This ranks candidates for one draft and returns the
8
+ winner, using the shared rewrite objective in slopscore (`rewrite_score`).
9
+
10
+ The order is not negotiable on fidelity. A version that invents a fact loses to any
11
+ version that does not, however much cleaner it reads, because inventing a detail is
12
+ the one thing hard rule 1 forbids. Of the versions that preserve the source, the
13
+ cleanest one wins.
14
+
15
+ python3 scripts/rerank.py --original draft.md cand1.md cand2.md cand3.md
16
+ python3 scripts/rerank.py --original draft.md --candidates cands.json # {name: text}
17
+ python3 scripts/rerank.py --original draft.md --genre linkedin *.md --out best.md
18
+
19
+ Prints a comparison table and marks the winner; writes the winning text to --out, or
20
+ to stdout with --emit. Offline, standard library only, like the rest of the scorer.
21
+ """
22
+ import json
23
+ import sys
24
+ import argparse
25
+ from pathlib import Path
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ sys.path.insert(0, str(ROOT / "scripts"))
29
+ from safeio import atomic_write_text
30
+
31
+
32
+ def _tier(s):
33
+ """0 clean · 1 dropped a fact · 2 invented a fact. Lower is better, always."""
34
+ if s["invented"]:
35
+ return 2
36
+ return 0 if s["preserved"] else 1
37
+
38
+
39
+ def rank(original, candidates, genre=None):
40
+ """candidates: {name: text}. Returns them scored and sorted, best first.
41
+
42
+ Sort key: fidelity tier first (a fabrication can never win), then soft quality,
43
+ then the tie-breaks the verify gate cares about — lower surface score, more
44
+ burstiness, fewer high-weight tells.
45
+ """
46
+ if not isinstance(original, str):
47
+ raise ValueError("original must be text")
48
+ if not isinstance(candidates, dict) or not candidates:
49
+ raise ValueError("candidates must be a non-empty object")
50
+ for name, text in candidates.items():
51
+ if (not isinstance(name, str) or not name.strip()
52
+ or not isinstance(text, str) or not text.strip()):
53
+ raise ValueError("candidate names and values must be non-empty names and text")
54
+ import slopscore
55
+ data = slopscore.load_patterns()
56
+ scored = []
57
+ for name, text in candidates.items():
58
+ s = slopscore.rewrite_score(original, text, genre, data)
59
+ s["name"], s["text"] = name, text
60
+ scored.append(s)
61
+ scored.sort(key=lambda s: (_tier(s), -s["soft"], s["after_ai"],
62
+ -s["burstiness"], s["high_tells"]))
63
+ return scored
64
+
65
+
66
+ def render(scored):
67
+ out = ["", " version writing score sentence variety strong flags source check",
68
+ " " + "-" * 88]
69
+ labels = {0: "source kept ✓", 1: "dropped a fact", 2: "ADDED A FACT ✗"}
70
+ for i, s in enumerate(scored):
71
+ mark = "→" if i == 0 else " "
72
+ variety = "natural" if s["burstiness"] >= 0.45 else "too even"
73
+ out.append(f" {mark} {s['name'][:18]:<18} {s['after_ai']:>13} "
74
+ f"{variety:<16} {s['high_tells']:>12} {labels[_tier(s)]}")
75
+ win = scored[0]
76
+ out.append("")
77
+ if _tier(win) == 0:
78
+ out.append(
79
+ f" Chosen: {win['name']} — the clearest of {len(scored)} versions "
80
+ "that preserve the source."
81
+ )
82
+ else:
83
+ out.append(
84
+ f" Chosen: {win['name']} — but every version changed or dropped "
85
+ f"source material ({labels[_tier(win)]}). Regenerate or fix the fact "
86
+ "before shipping."
87
+ )
88
+ return "\n".join(out)
89
+
90
+
91
+ def main(argv=None):
92
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
93
+ ap.add_argument("--original", required=True, metavar="FILE")
94
+ ap.add_argument("--candidates", metavar="JSON_FILE")
95
+ ap.add_argument("--genre")
96
+ ap.add_argument("--out", metavar="FILE")
97
+ ap.add_argument("--emit", action="store_true")
98
+ ap.add_argument("files", nargs="*")
99
+ args = ap.parse_args(argv)
100
+ try:
101
+ original = Path(args.original).read_text()
102
+ except (OSError, UnicodeDecodeError) as exc:
103
+ ap.error(f"cannot read original: {exc}")
104
+
105
+ if args.candidates:
106
+ if args.files:
107
+ ap.error("use either --candidates JSON_FILE or candidate files, not both")
108
+ try:
109
+ candidates = json.loads(Path(args.candidates).read_text())
110
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
111
+ ap.error(f"cannot read candidates: {exc}")
112
+ else:
113
+ candidates = {}
114
+ for file_name in args.files:
115
+ path = Path(file_name)
116
+ if path.name in candidates:
117
+ ap.error(f"candidate basenames must be unique: {path.name}")
118
+ try:
119
+ candidates[path.name] = path.read_text()
120
+ except (OSError, UnicodeDecodeError) as exc:
121
+ ap.error(f"cannot read candidate {path}: {exc}")
122
+ if len(candidates) < 2:
123
+ ap.error("give at least two candidate rewrites to choose between")
124
+
125
+ try:
126
+ scored = rank(original, candidates, args.genre)
127
+ except ValueError as exc:
128
+ ap.error(str(exc))
129
+ print(render(scored))
130
+ win = scored[0]
131
+ if args.out:
132
+ output = Path(args.out).resolve()
133
+ protected = {Path(args.original).resolve()}
134
+ if args.candidates:
135
+ protected.add(Path(args.candidates).resolve())
136
+ protected.update(Path(path).resolve() for path in args.files)
137
+ if output in protected:
138
+ ap.error("--out must not overwrite the original or a candidate input")
139
+ try:
140
+ atomic_write_text(output, win["text"])
141
+ except OSError as exc:
142
+ ap.error(f"cannot write winner: {exc}")
143
+ print(f"\n wrote {args.out}")
144
+ elif args.emit:
145
+ print("\n" + "=" * 60 + "\n" + win["text"])
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(main())
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Small, stdlib-only primitives for durable and concurrency-safe state updates.
3
+
4
+ Zero Slop's scorer is read-only, but the learning and calibration tools perform
5
+ read-modify-write updates. Atomic replacement prevents torn JSON; lock directories
6
+ prevent two processes from silently overwriting each other's observations.
7
+ """
8
+ from contextlib import contextmanager
9
+ import hashlib
10
+ import os
11
+ from pathlib import Path
12
+ import stat
13
+ import tempfile
14
+ import time
15
+
16
+
17
+ LOCK_TIMEOUT = 30.0
18
+ STALE_AFTER = 3600.0
19
+
20
+
21
+ def _lock_path(path):
22
+ path = Path(path).resolve()
23
+ digest = hashlib.sha256(str(path).encode()).hexdigest()[:12]
24
+ return path.parent / f".{path.name}.{digest}.lock"
25
+
26
+
27
+ @contextmanager
28
+ def file_locks(paths, timeout=LOCK_TIMEOUT, stale_after=STALE_AFTER):
29
+ """Acquire process-safe locks for several files in a stable order.
30
+
31
+ Directory creation is atomic on every supported platform. Locks older than an
32
+ hour are treated as abandoned after a crash. All locks are acquired in sorted
33
+ order, so two commands touching the same files cannot deadlock each other.
34
+ """
35
+ targets = sorted({_lock_path(p) for p in paths}, key=lambda p: str(p))
36
+ acquired = []
37
+ deadline = time.monotonic() + timeout
38
+ try:
39
+ for lock in targets:
40
+ lock.parent.mkdir(parents=True, exist_ok=True)
41
+ while True:
42
+ try:
43
+ lock.mkdir(mode=0o700)
44
+ acquired.append(lock)
45
+ break
46
+ except FileExistsError:
47
+ try:
48
+ if time.time() - lock.stat().st_mtime > stale_after:
49
+ lock.rmdir()
50
+ continue
51
+ except (FileNotFoundError, OSError):
52
+ continue
53
+ if time.monotonic() >= deadline:
54
+ raise SystemExit(
55
+ f"Zero Slop state is busy ({lock.name}); retry in a moment."
56
+ )
57
+ time.sleep(0.05)
58
+ yield
59
+ finally:
60
+ for lock in reversed(acquired):
61
+ try:
62
+ lock.rmdir()
63
+ except FileNotFoundError:
64
+ pass
65
+
66
+
67
+ def atomic_write_text(path, text, *, mode=None):
68
+ """Durably replace a text file without exposing a partial write."""
69
+ path = Path(path)
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ if mode is None and path.exists():
72
+ mode = stat.S_IMODE(path.stat().st_mode)
73
+ mode = 0o644 if mode is None else mode
74
+ tmp_name = None
75
+ try:
76
+ with tempfile.NamedTemporaryFile(
77
+ "w", encoding="utf-8", dir=path.parent,
78
+ prefix=f".{path.name}.", suffix=".tmp", delete=False) as fh:
79
+ tmp_name = fh.name
80
+ fh.write(text)
81
+ fh.flush()
82
+ os.fsync(fh.fileno())
83
+ os.chmod(tmp_name, mode)
84
+ os.replace(tmp_name, path)
85
+ tmp_name = None
86
+ # Make the rename durable where directory fsync is available.
87
+ try:
88
+ fd = os.open(path.parent, os.O_RDONLY)
89
+ try:
90
+ os.fsync(fd)
91
+ finally:
92
+ os.close(fd)
93
+ except OSError:
94
+ pass
95
+ finally:
96
+ if tmp_name:
97
+ try:
98
+ os.unlink(tmp_name)
99
+ except FileNotFoundError:
100
+ pass
101
+
102
+
103
+ def atomic_write_bytes(path, data, *, mode=None):
104
+ """Binary counterpart to :func:`atomic_write_text`."""
105
+ if not isinstance(data, (bytes, bytearray, memoryview)):
106
+ raise TypeError("data must be bytes-like")
107
+ path = Path(path)
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ if mode is None and path.exists():
110
+ mode = stat.S_IMODE(path.stat().st_mode)
111
+ mode = 0o644 if mode is None else mode
112
+ tmp_name = None
113
+ try:
114
+ with tempfile.NamedTemporaryFile(
115
+ "wb", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp",
116
+ delete=False) as fh:
117
+ tmp_name = fh.name
118
+ fh.write(data)
119
+ fh.flush()
120
+ os.fsync(fh.fileno())
121
+ os.chmod(tmp_name, mode)
122
+ os.replace(tmp_name, path)
123
+ tmp_name = None
124
+ try:
125
+ fd = os.open(path.parent, os.O_RDONLY)
126
+ try:
127
+ os.fsync(fd)
128
+ finally:
129
+ os.close(fd)
130
+ except OSError:
131
+ pass
132
+ finally:
133
+ if tmp_name:
134
+ try:
135
+ os.unlink(tmp_name)
136
+ except FileNotFoundError:
137
+ pass
138
+
139
+
140
+ def is_within(path, parent):
141
+ """True only when the resolved path is inside the resolved parent."""
142
+ try:
143
+ Path(path).resolve().relative_to(Path(parent).resolve())
144
+ return True
145
+ except ValueError:
146
+ return False