bmad-module-skill-forge 3.0.0 → 5.0.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/architecture.md +2 -2
- package/docs/concepts.md +4 -4
- package/docs/examples.md +33 -26
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +46 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +5 -5
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
- package/src/shared/scripts/skf-extract-public-api.py +1673 -189
- package/src/shared/scripts/skf-extraction-inventory.py +584 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-parse-gaps.py +135 -26
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +2 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +3 -3
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-campaign/SKILL.md +2 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +7 -7
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +23 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +10 -11
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +21 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +2 -2
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-export-skill/SKILL.md +2 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +6 -8
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-setup/SKILL.md +2 -2
- package/src/skf-test-skill/SKILL.md +2 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +5 -4
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +69 -111
- package/src/skf-test-skill/references/init.md +5 -6
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +11 -9
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +15 -18
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
- package/src/skf-test-skill/scripts/score-signatures.py +356 -61
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +2 -2
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +37 -22
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +17 -12
- package/src/skf-update-skill/references/re-extract.md +4 -2
- package/src/skf-update-skill/references/report.md +7 -4
- package/src/skf-update-skill/references/write.md +77 -36
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -23,17 +23,22 @@ Subcommands:
|
|
|
23
23
|
|
|
24
24
|
flip-link Point the link <link> at <target>: create the new link as
|
|
25
25
|
<link>.skf-tmp-link, then os.replace it over <link>, holding an
|
|
26
|
-
exclusive lock on <link>.skf-lock
|
|
27
|
-
|
|
26
|
+
exclusive lock on <link>.skf-lock, which only the flip holding
|
|
27
|
+
it removes. skf-update-active-symlink.py update takes the same
|
|
28
|
+
lock (take_flip_lock), so one flip of a link runs at a time.
|
|
29
|
+
Refuses to replace a <link> that is a real directory or file.
|
|
28
30
|
|
|
29
31
|
Cross-platform: locking branches between fcntl (POSIX) and msvcrt
|
|
30
32
|
(Windows). On Windows os.symlink needs Developer Mode or admin; when it
|
|
31
33
|
fails for lack of privilege, flip-link creates a directory junction
|
|
32
34
|
(mklink /J) instead, which needs neither but requires <target> to be an
|
|
33
|
-
existing directory.
|
|
34
|
-
so on
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
existing directory. Windows renames nothing over an existing directory
|
|
36
|
+
link, so on the junction path, and on Windows whenever the rename over the
|
|
37
|
+
old link is refused, replace_link removes the old link first (a junction
|
|
38
|
+
whose folder is gone included) and the flip is not atomic. The Windows
|
|
39
|
+
tests in test-skf-atomic-write.py and test-skf-update-active-symlink.py
|
|
40
|
+
make os.symlink fail as it does without the privilege, so CI's Windows
|
|
41
|
+
runner, which holds it, runs the junction path too.
|
|
37
42
|
|
|
38
43
|
Exit codes:
|
|
39
44
|
0 on success (JSON on stdout)
|
|
@@ -44,8 +49,11 @@ Exit codes:
|
|
|
44
49
|
2 on a refused or failed operation, with {"status": "error", "message":
|
|
45
50
|
...} on stderr: write failed, stage-dir could not clear a stale entry,
|
|
46
51
|
commit-dir found a non-directory target or could not swap, flip-link
|
|
47
|
-
found a non-link at <link
|
|
48
|
-
|
|
52
|
+
found a non-link at <link>, another process holding its lock, a
|
|
53
|
+
lock file Windows kept refusing to open for its short wait while the
|
|
54
|
+
file was being removed, or (on POSIX) a locked lock file that kept
|
|
55
|
+
leaving the path for that same wait; and on bad arguments (argparse
|
|
56
|
+
usage on stderr, no JSON)
|
|
49
57
|
|
|
50
58
|
CLI examples:
|
|
51
59
|
cat metadata.json | python3 skf-atomic-write.py write --target /path/to/metadata.json
|
|
@@ -63,6 +71,7 @@ import os
|
|
|
63
71
|
import shutil
|
|
64
72
|
import subprocess
|
|
65
73
|
import sys
|
|
74
|
+
import time
|
|
66
75
|
from pathlib import Path
|
|
67
76
|
|
|
68
77
|
if os.name == "nt":
|
|
@@ -70,6 +79,18 @@ if os.name == "nt":
|
|
|
70
79
|
else:
|
|
71
80
|
import fcntl
|
|
72
81
|
|
|
82
|
+
# While the flip that held <link>.skf-lock is removing it, Windows refuses a
|
|
83
|
+
# new open of the file with PermissionError. flip-link takes that for a held
|
|
84
|
+
# lock and tries again for up to this long (a flip fails fast on contention
|
|
85
|
+
# by design, so the wait is short), then exits 2 as it does for a held lock.
|
|
86
|
+
# The same wait bounds a POSIX flip that keeps finding the file it locked
|
|
87
|
+
# gone from the path: it opens the path again until then, then exits 2.
|
|
88
|
+
# A caller that gives take_flip_lock a longer `wait` (skf-update-active-symlink.py
|
|
89
|
+
# update) keeps both retries going until that wait ends.
|
|
90
|
+
_IS_WINDOWS = os.name == "nt"
|
|
91
|
+
_LOCK_OPEN_WAIT_SECONDS = 1.0
|
|
92
|
+
_LOCK_OPEN_POLL_SECONDS = 0.02
|
|
93
|
+
|
|
73
94
|
|
|
74
95
|
def _create_symlink_or_junction(target: str, link_path: Path) -> str:
|
|
75
96
|
"""Create a directory link from link_path to target.
|
|
@@ -141,6 +162,16 @@ def _release_lock(fd: int) -> None:
|
|
|
141
162
|
pass
|
|
142
163
|
|
|
143
164
|
|
|
165
|
+
# Keep identical to _names_open_file in gap-ledger.py
|
|
166
|
+
# (test/test-skf-atomic-write.py pins the copy).
|
|
167
|
+
def _names_open_file(fd: int, path: Path) -> bool:
|
|
168
|
+
"""True when `path` still names the file open as `fd`."""
|
|
169
|
+
try:
|
|
170
|
+
return os.path.samestat(os.fstat(fd), os.stat(path))
|
|
171
|
+
except OSError:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
144
175
|
def _die(code: int, message: str) -> None:
|
|
145
176
|
print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
|
|
146
177
|
sys.exit(code)
|
|
@@ -282,18 +313,182 @@ def _is_link_or_junction(p: Path) -> bool:
|
|
|
282
313
|
return False
|
|
283
314
|
|
|
284
315
|
|
|
316
|
+
def _remove_link(p: Path) -> None:
|
|
317
|
+
"""Remove the symlink or junction at p, whether or not the folder it names exists.
|
|
318
|
+
|
|
319
|
+
os.readlink raises for a real folder or file (OSError) and for another
|
|
320
|
+
Windows reparse point (ValueError), so neither is ever removed. os.unlink
|
|
321
|
+
removes a symlink, and on Windows a directory symlink or a junction too;
|
|
322
|
+
os.rmdir is the fallback for a directory link unlink refuses.
|
|
323
|
+
"""
|
|
324
|
+
if not p.is_symlink():
|
|
325
|
+
os.readlink(p)
|
|
326
|
+
try:
|
|
327
|
+
os.unlink(p)
|
|
328
|
+
except OSError:
|
|
329
|
+
os.rmdir(p)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def replace_link(link: Path, target: str, tmp_link: Path | None = None) -> str:
|
|
333
|
+
"""Point the link <link> at <target> through <tmp_link>; return "symlink" or "junction".
|
|
334
|
+
|
|
335
|
+
The caller holds take_flip_lock(link) and has refused a real folder or
|
|
336
|
+
file at <link>. A stale <tmp_link> from a crashed flip is removed first.
|
|
337
|
+
The new link is made at <tmp_link> and renamed over <link>, which is
|
|
338
|
+
atomic for a symlink on POSIX. Windows renames nothing over an existing
|
|
339
|
+
directory link, so the old link is removed first on the junction path,
|
|
340
|
+
and on Windows when the rename is refused: a reader can then find no
|
|
341
|
+
<link> for a moment. A failure removes <tmp_link> and raises.
|
|
342
|
+
"""
|
|
343
|
+
if tmp_link is None:
|
|
344
|
+
tmp_link = link.with_name(link.name + ".skf-tmp-link")
|
|
345
|
+
if os.path.lexists(tmp_link):
|
|
346
|
+
_remove_link(tmp_link)
|
|
347
|
+
link_kind = _create_symlink_or_junction(target, tmp_link)
|
|
348
|
+
try:
|
|
349
|
+
if link_kind == "junction" and os.path.lexists(link):
|
|
350
|
+
_remove_link(link)
|
|
351
|
+
try:
|
|
352
|
+
os.replace(tmp_link, link)
|
|
353
|
+
except OSError:
|
|
354
|
+
if not _IS_WINDOWS or not os.path.lexists(link):
|
|
355
|
+
raise
|
|
356
|
+
_remove_link(link)
|
|
357
|
+
os.replace(tmp_link, link)
|
|
358
|
+
except BaseException:
|
|
359
|
+
try:
|
|
360
|
+
if os.path.lexists(tmp_link):
|
|
361
|
+
_remove_link(tmp_link)
|
|
362
|
+
except (OSError, ValueError):
|
|
363
|
+
pass
|
|
364
|
+
raise
|
|
365
|
+
return link_kind
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class FlipLockHeld(Exception):
|
|
369
|
+
"""Another flip holds <link>.skf-lock; the message names <link>."""
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def take_flip_lock(link: Path, wait: float = 0.0) -> int:
|
|
373
|
+
"""Take <link>.skf-lock, the lock every flip of <link> holds, and return its fd.
|
|
374
|
+
|
|
375
|
+
flip-link and skf-update-active-symlink.py update both take it, so one
|
|
376
|
+
flip of a link runs at a time. `wait` is how long to keep trying a lock
|
|
377
|
+
another flip holds: the call closes the file, sleeps
|
|
378
|
+
_LOCK_OPEN_POLL_SECONDS and opens the path again. flip-link passes 0
|
|
379
|
+
and is refused at once. Raises FlipLockHeld when the lock stays held
|
|
380
|
+
past `wait`, and when the retries cmd_flip_link's docstring describes
|
|
381
|
+
(Windows refusing the open, a locked file that left the path) run out:
|
|
382
|
+
they run until max(_LOCK_OPEN_WAIT_SECONDS, wait). release_flip_lock
|
|
383
|
+
gives the lock back.
|
|
384
|
+
"""
|
|
385
|
+
lock_path = link.with_name(link.name + ".skf-lock")
|
|
386
|
+
held_deadline = time.monotonic() + wait
|
|
387
|
+
deadline = time.monotonic() + max(_LOCK_OPEN_WAIT_SECONDS, wait)
|
|
388
|
+
vanished = False
|
|
389
|
+
while True:
|
|
390
|
+
try:
|
|
391
|
+
lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
392
|
+
except PermissionError as exc:
|
|
393
|
+
# Windows refuses to open a file whose removal is pending. A folder
|
|
394
|
+
# at the path, or nothing there twice running (once can be the
|
|
395
|
+
# removal ending just now), is a lasting denial: raise it.
|
|
396
|
+
if not _IS_WINDOWS or os.path.isdir(lock_path):
|
|
397
|
+
raise
|
|
398
|
+
if not os.path.lexists(lock_path):
|
|
399
|
+
if vanished:
|
|
400
|
+
raise
|
|
401
|
+
vanished = True
|
|
402
|
+
continue
|
|
403
|
+
vanished = False
|
|
404
|
+
if time.monotonic() >= deadline:
|
|
405
|
+
raise FlipLockHeld(f"another process holds flip lock on {link} (last open refused: {exc})")
|
|
406
|
+
time.sleep(_LOCK_OPEN_POLL_SECONDS)
|
|
407
|
+
continue
|
|
408
|
+
held = False
|
|
409
|
+
try:
|
|
410
|
+
try:
|
|
411
|
+
_acquire_lock(lock_fd)
|
|
412
|
+
except OSError as e:
|
|
413
|
+
if e.errno != errno.EAGAIN:
|
|
414
|
+
raise
|
|
415
|
+
if time.monotonic() >= held_deadline:
|
|
416
|
+
raise FlipLockHeld(f"another process holds flip lock on {link}")
|
|
417
|
+
held = True
|
|
418
|
+
else:
|
|
419
|
+
# os.name here and in release_flip_lock follows the real OS; _IS_WINDOWS is the test seam of the
|
|
420
|
+
# open retry and of replace_link's remove-first retry.
|
|
421
|
+
if os.name == "nt" or _names_open_file(lock_fd, lock_path):
|
|
422
|
+
return lock_fd
|
|
423
|
+
_release_lock(lock_fd)
|
|
424
|
+
except BaseException:
|
|
425
|
+
os.close(lock_fd)
|
|
426
|
+
raise
|
|
427
|
+
os.close(lock_fd)
|
|
428
|
+
if held:
|
|
429
|
+
time.sleep(_LOCK_OPEN_POLL_SECONDS)
|
|
430
|
+
continue
|
|
431
|
+
if time.monotonic() >= deadline:
|
|
432
|
+
raise FlipLockHeld(f"another process holds flip lock on {link}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def release_flip_lock(link: Path, lock_fd: int) -> None:
|
|
436
|
+
"""Give back the lock take_flip_lock returned, removing <link>.skf-lock."""
|
|
437
|
+
lock_path = link.with_name(link.name + ".skf-lock")
|
|
438
|
+
# Only the flip holding the lock gets here, so it alone removes the
|
|
439
|
+
# lock file: on POSIX while the lock is still held, so that a flip on
|
|
440
|
+
# the old file sees the path no longer names it; on Windows, which
|
|
441
|
+
# removes no open file, after the release and the close. Best effort:
|
|
442
|
+
# a leftover file is locked and removed by the next flip.
|
|
443
|
+
if os.name != "nt":
|
|
444
|
+
try:
|
|
445
|
+
lock_path.unlink()
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
_release_lock(lock_fd)
|
|
449
|
+
os.close(lock_fd)
|
|
450
|
+
if os.name == "nt":
|
|
451
|
+
try:
|
|
452
|
+
lock_path.unlink()
|
|
453
|
+
except OSError:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
|
|
285
457
|
def cmd_flip_link(link: Path, target: str) -> None:
|
|
286
458
|
"""Atomically point <link> at <target> using rename-over-symlink pattern.
|
|
287
459
|
|
|
288
460
|
target is the value of the symlink (may be relative, as is convention
|
|
289
|
-
for `active -> 1.0.0`). A lock on <link>.skf-lock
|
|
461
|
+
for `active -> 1.0.0`). A lock on <link>.skf-lock, which
|
|
462
|
+
skf-update-active-symlink.py update takes too, prevents concurrent flips.
|
|
290
463
|
|
|
291
464
|
On Windows, os.symlink requires Developer Mode or admin. When that fails
|
|
292
465
|
with PRIVILEGE_NOT_HELD/ACCESS_DENIED the helper falls back to a directory
|
|
293
466
|
junction (no elevation needed); junctions resolve identically for
|
|
294
|
-
skf-skill-inventory's resolve_active_version().
|
|
467
|
+
skf-skill-inventory's resolve_active_version(). replace_link makes the
|
|
468
|
+
new link and renames it over <link>; skf-update-active-symlink.py update
|
|
469
|
+
flips through it too.
|
|
470
|
+
|
|
471
|
+
The lock follows ledger_lock in gap-ledger.py (the same identity check
|
|
472
|
+
and removal order): a fix to one belongs in both. The lock file is
|
|
473
|
+
removed on release, by the flip that held the lock: a flip refused by a
|
|
474
|
+
held lock exits 2 and leaves the file to its holder. On POSIX the
|
|
475
|
+
removal happens while the lock is still held, and a flip checks that
|
|
476
|
+
the file it locked is still the one at the path: otherwise a flip on
|
|
477
|
+
the removed file and a newcomer on a new one would both get a lock. A
|
|
478
|
+
flip whose file is no longer at the path lets it go and opens the path
|
|
479
|
+
again: it goes on as soon as it locks the file that is at the path, and
|
|
480
|
+
exits 2 at once when another flip holds that file. Only when the files
|
|
481
|
+
it locks keep leaving the path until _LOCK_OPEN_WAIT_SECONDS runs out
|
|
482
|
+
does it exit 2 with the "another process holds flip lock" message a
|
|
483
|
+
held lock gives. Windows removes no file that is open, so there the file goes after the
|
|
484
|
+
release, unless another flip has it open. While the removal is under
|
|
485
|
+
way (the remover's own delete handle is open, or a virus scanner's or
|
|
486
|
+
the search indexer's), Windows refuses a new open of the file with
|
|
487
|
+
PermissionError: the flip retries for up to _LOCK_OPEN_WAIT_SECONDS,
|
|
488
|
+
then exits 2 with the held-lock message, naming the last refusal. A
|
|
489
|
+
refusal with a folder at the path, or with nothing there on an
|
|
490
|
+
immediate second try, is a lasting denial and is raised as before.
|
|
295
491
|
"""
|
|
296
|
-
lock_path = link.with_name(link.name + ".skf-lock")
|
|
297
492
|
link.parent.mkdir(parents=True, exist_ok=True)
|
|
298
493
|
|
|
299
494
|
# Refuse only if <link> is a real directory/file (not a symlink or junction).
|
|
@@ -301,56 +496,14 @@ def cmd_flip_link(link: Path, target: str) -> None:
|
|
|
301
496
|
if link.exists() and not _is_link_or_junction(link):
|
|
302
497
|
_die(2, f"refusing to replace non-link: {link}")
|
|
303
498
|
|
|
304
|
-
lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
305
|
-
lock_held = False
|
|
306
499
|
try:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
_die(2, f"another process holds flip lock on {link}")
|
|
313
|
-
raise
|
|
314
|
-
|
|
315
|
-
tmp_link = link.with_name(link.name + ".skf-tmp-link")
|
|
316
|
-
if tmp_link.is_symlink() or tmp_link.exists():
|
|
317
|
-
# On Windows a junction returns is_dir()=True, exists()=True but
|
|
318
|
-
# is_symlink()=False — must rmdir/unlink based on type.
|
|
319
|
-
if tmp_link.is_dir() and not tmp_link.is_symlink():
|
|
320
|
-
tmp_link.rmdir()
|
|
321
|
-
else:
|
|
322
|
-
tmp_link.unlink()
|
|
323
|
-
try:
|
|
324
|
-
link_kind = _create_symlink_or_junction(target, tmp_link)
|
|
325
|
-
except OSError as e:
|
|
326
|
-
if os.name == "nt" and getattr(e, "winerror", None) in (1314, 5):
|
|
327
|
-
_die(
|
|
328
|
-
2,
|
|
329
|
-
"symlink/junction creation failed on Windows. Junction fallback "
|
|
330
|
-
"requires the target directory to exist. Either enable Developer "
|
|
331
|
-
"Mode (Settings → Privacy & Security → For Developers) or use WSL2.",
|
|
332
|
-
)
|
|
333
|
-
raise
|
|
334
|
-
# os.replace can swap a symlink-over-symlink atomically, but Windows
|
|
335
|
-
# rejects renaming a directory (junction) over an existing directory
|
|
336
|
-
# (junction or real). Drop the existing link first when junction-based.
|
|
337
|
-
if link_kind == "junction" and (link.is_dir() or link.is_symlink() or link.exists()):
|
|
338
|
-
if link.is_symlink() or not link.is_dir():
|
|
339
|
-
link.unlink()
|
|
340
|
-
else:
|
|
341
|
-
link.rmdir()
|
|
342
|
-
os.replace(tmp_link, link)
|
|
500
|
+
lock_fd = take_flip_lock(link)
|
|
501
|
+
except FlipLockHeld as exc:
|
|
502
|
+
_die(2, str(exc))
|
|
503
|
+
try:
|
|
504
|
+
link_kind = replace_link(link, target)
|
|
343
505
|
finally:
|
|
344
|
-
|
|
345
|
-
_release_lock(lock_fd)
|
|
346
|
-
os.close(lock_fd)
|
|
347
|
-
# Lock file is bookkeeping only — once the fd is closed the lock is
|
|
348
|
-
# gone, so leaving the file around just litters the skill_group dir.
|
|
349
|
-
# Best-effort unlink; a concurrent flipper may have already removed it.
|
|
350
|
-
try:
|
|
351
|
-
lock_path.unlink()
|
|
352
|
-
except OSError:
|
|
353
|
-
pass
|
|
506
|
+
release_flip_lock(link, lock_fd)
|
|
354
507
|
|
|
355
508
|
_ok({"link": str(link), "points_to": target, "kind": link_kind})
|
|
356
509
|
|