@christang/keel 5.60.0 → 5.61.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.61.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.61.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import contextlib
|
|
6
7
|
import json
|
|
7
8
|
import hashlib
|
|
8
9
|
import os
|
|
@@ -37,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
38
|
"scripts/validate_plugin.py",
|
|
38
39
|
]
|
|
39
40
|
|
|
40
|
-
PACKAGE_VERSION = "5.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.61.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.61.0"
|
|
42
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -5521,36 +5522,46 @@ def validate_a_declared_dependency_is_resolved_scenario() -> int:
|
|
|
5521
5522
|
"""
|
|
5522
5523
|
label = "a-declared-dependency-is-resolved"
|
|
5523
5524
|
|
|
5524
|
-
# `no openspec on PATH`, and nothing else removed
|
|
5525
|
-
# would also remove `node`, and the
|
|
5526
|
-
# would
|
|
5527
|
-
without
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
if
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5525
|
+
# `no openspec on PATH`, and nothing else removed — by file, not by
|
|
5526
|
+
# directory. Removing the directory would also remove `node`, and the
|
|
5527
|
+
# openspec shim needs it, so the scenario would be asserting that a shell
|
|
5528
|
+
# without an interpreter fails (issue #137).
|
|
5529
|
+
with path_without_openspec() as sanitized:
|
|
5530
|
+
without = dict(os.environ)
|
|
5531
|
+
without["PATH"] = sanitized
|
|
5532
|
+
if shutil.which("openspec", path=without["PATH"]) is not None:
|
|
5533
|
+
report(
|
|
5534
|
+
f"{label}: could not build a PATH without openspec on it, so the "
|
|
5535
|
+
"reproduction cannot be set up."
|
|
5536
|
+
)
|
|
5537
|
+
return 1
|
|
5538
|
+
# The scenario's own precondition, asserted before the behavior. A shim
|
|
5539
|
+
# with no interpreter exits 127, and calling that a fact about openspec
|
|
5540
|
+
# sends the reader to a tool with nothing wrong with it.
|
|
5541
|
+
if shutil.which("node", path=without["PATH"]) is None:
|
|
5542
|
+
return skip_scenario(
|
|
5543
|
+
label,
|
|
5544
|
+
"node does not resolve on the PATH this scenario built, so the "
|
|
5545
|
+
"openspec shim it resolves would have no interpreter. That is a "
|
|
5546
|
+
"broken fixture, not a fact about openspec.",
|
|
5547
|
+
)
|
|
5548
|
+
resolved = run_openspec(ROOT, "--version", env=without)
|
|
5549
|
+
if resolved is None:
|
|
5550
|
+
report(
|
|
5551
|
+
f"{label}: with no `openspec` on PATH the runner resolved "
|
|
5552
|
+
"nothing, but this package declares it as a dependency and "
|
|
5553
|
+
"installs it at node_modules/.bin. A tool the repository ships "
|
|
5554
|
+
"is not a tool the host has to provide."
|
|
5555
|
+
)
|
|
5556
|
+
return 1
|
|
5557
|
+
if resolved.returncode != 0 or not re.search(
|
|
5558
|
+
r"\d+\.\d+\.\d+", resolved.stdout
|
|
5559
|
+
):
|
|
5560
|
+
report(
|
|
5561
|
+
f"{label}: the resolved openspec did not report a version. "
|
|
5562
|
+
f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
|
|
5563
|
+
)
|
|
5564
|
+
return 1
|
|
5554
5565
|
|
|
5555
5566
|
# And it is the declared one, not whatever a host happens to carry.
|
|
5556
5567
|
declared = ROOT / "node_modules" / ".bin" / "openspec"
|
|
@@ -14043,6 +14054,60 @@ OPENSPEC_SEARCH_ORDER = (
|
|
|
14043
14054
|
)
|
|
14044
14055
|
|
|
14045
14056
|
|
|
14057
|
+
@contextlib.contextmanager
|
|
14058
|
+
def path_without_openspec(path: str | None = None):
|
|
14059
|
+
"""Yield `path` with every `openspec` executable on it removed, and nothing else.
|
|
14060
|
+
|
|
14061
|
+
Dropping a PATH *directory* because it holds an `openspec` also drops that
|
|
14062
|
+
directory's other contents. On the ordinary Homebrew plus `npm install -g`
|
|
14063
|
+
layout one directory holds both `openspec` and `node`, so the filter took the
|
|
14064
|
+
interpreter the openspec shim needs and the scenario built on it asserted that
|
|
14065
|
+
a shell without an interpreter fails — reported against `openspec`, which was
|
|
14066
|
+
installed and working (issue #137).
|
|
14067
|
+
|
|
14068
|
+
So a directory that holds the tool is replaced in place, at the same index, by
|
|
14069
|
+
a mirror symlinking every one of its entries except the `openspec` ones; a
|
|
14070
|
+
directory that does not hold it is passed through untouched. Exclusion is by
|
|
14071
|
+
stem, so `openspec.cmd` and its siblings cannot survive a filter whose whole
|
|
14072
|
+
purpose is that the name does not resolve.
|
|
14073
|
+
|
|
14074
|
+
The mirrors live for the duration of the context, so callers must hold it open
|
|
14075
|
+
across every child process they run against the PATH, not only across the call
|
|
14076
|
+
that built it.
|
|
14077
|
+
"""
|
|
14078
|
+
original = os.environ.get("PATH", "") if path is None else path
|
|
14079
|
+
entries = [entry for entry in original.split(os.pathsep) if entry]
|
|
14080
|
+
with tempfile.TemporaryDirectory(prefix="keel-no-openspec-") as raw:
|
|
14081
|
+
mirrors = Path(raw)
|
|
14082
|
+
rebuilt: list[str] = []
|
|
14083
|
+
for index, entry in enumerate(entries):
|
|
14084
|
+
source = Path(entry)
|
|
14085
|
+
try:
|
|
14086
|
+
contents = sorted(source.iterdir())
|
|
14087
|
+
except OSError:
|
|
14088
|
+
# Unreadable or absent: it carried nothing resolvable either way.
|
|
14089
|
+
rebuilt.append(entry)
|
|
14090
|
+
continue
|
|
14091
|
+
if not any(item.stem == "openspec" for item in contents):
|
|
14092
|
+
rebuilt.append(entry)
|
|
14093
|
+
continue
|
|
14094
|
+
mirror = mirrors / str(index)
|
|
14095
|
+
try:
|
|
14096
|
+
mirror.mkdir()
|
|
14097
|
+
for item in contents:
|
|
14098
|
+
if item.stem == "openspec":
|
|
14099
|
+
continue
|
|
14100
|
+
(mirror / item.name).symlink_to(item)
|
|
14101
|
+
except OSError:
|
|
14102
|
+
# Windows can refuse a symlink without the privilege. Fall back to
|
|
14103
|
+
# dropping the directory, which is what this replaces; the caller's
|
|
14104
|
+
# precondition check then reports the consequence by name instead
|
|
14105
|
+
# of attributing it to the tool.
|
|
14106
|
+
continue
|
|
14107
|
+
rebuilt.append(str(mirror))
|
|
14108
|
+
yield os.pathsep.join(rebuilt)
|
|
14109
|
+
|
|
14110
|
+
|
|
14046
14111
|
def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
|
|
14047
14112
|
local = ROOT / "node_modules" / ".bin" / "openspec"
|
|
14048
14113
|
if local.is_file():
|
|
@@ -24659,14 +24724,13 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
|
|
|
24659
24724
|
shutil.copy2(ROOT / "package.json", package_root / "package.json")
|
|
24660
24725
|
|
|
24661
24726
|
# PATH without any openspec, so what resolves came from the layout and not
|
|
24662
|
-
# from the machine running the suite.
|
|
24727
|
+
# from the machine running the suite. Removed by file rather than by
|
|
24728
|
+
# directory: one directory holds `openspec` and `node` on the ordinary
|
|
24729
|
+
# Homebrew plus `npm install -g` layout, and the children below are run by
|
|
24730
|
+
# `node` (issue #137).
|
|
24663
24731
|
def clean_env(extra_path: Path | None = None) -> dict[str, str]:
|
|
24664
24732
|
env = dict(os.environ)
|
|
24665
|
-
entries = [
|
|
24666
|
-
entry
|
|
24667
|
-
for entry in env.get("PATH", "").split(os.pathsep)
|
|
24668
|
-
if entry and not (Path(entry) / "openspec").exists()
|
|
24669
|
-
]
|
|
24733
|
+
entries = [entry for entry in sanitized_path.split(os.pathsep) if entry]
|
|
24670
24734
|
if extra_path is not None:
|
|
24671
24735
|
entries.insert(0, str(extra_path))
|
|
24672
24736
|
env["PATH"] = os.pathsep.join(entries)
|
|
@@ -24696,7 +24760,21 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
|
|
|
24696
24760
|
"",
|
|
24697
24761
|
)
|
|
24698
24762
|
|
|
24699
|
-
|
|
24763
|
+
# The mirrors live for the duration of the context, so it stays open across
|
|
24764
|
+
# every child process below rather than only across the call that built it.
|
|
24765
|
+
with path_without_openspec() as sanitized_path, tempfile.TemporaryDirectory(
|
|
24766
|
+
prefix="keel-hoisted-"
|
|
24767
|
+
) as raw:
|
|
24768
|
+
# The scenario's own precondition, asserted before the behavior: every
|
|
24769
|
+
# assertion below runs `node`, so a PATH without one is a broken fixture
|
|
24770
|
+
# and not a fact about where the dependency resolves.
|
|
24771
|
+
if shutil.which("node", path=sanitized_path) is None:
|
|
24772
|
+
return skip_scenario(
|
|
24773
|
+
label,
|
|
24774
|
+
"node does not resolve on the PATH this scenario built, and "
|
|
24775
|
+
"every assertion below runs the installed Keel through it. That "
|
|
24776
|
+
"is a broken fixture, not a fact about openspec.",
|
|
24777
|
+
)
|
|
24700
24778
|
root = Path(raw)
|
|
24701
24779
|
|
|
24702
24780
|
# The layout npm actually produces: Keel unpacked under the consumer's
|
|
@@ -27445,6 +27523,109 @@ def validate_authored_scenario_names_scenario() -> int:
|
|
|
27445
27523
|
return 0
|
|
27446
27524
|
|
|
27447
27525
|
|
|
27526
|
+
def validate_a_filter_drops_only_what_it_named_scenario() -> int:
|
|
27527
|
+
"""Issue #137: a PATH built to exclude one tool removed a whole directory.
|
|
27528
|
+
|
|
27529
|
+
Both scenarios that need "no openspec on PATH" dropped every directory that
|
|
27530
|
+
held an `openspec`. On the ordinary Homebrew plus `npm install -g` layout one
|
|
27531
|
+
directory holds `openspec` and `node`, so the filter took the interpreter the
|
|
27532
|
+
openspec shim needs, and the diagnostic named `openspec` — a tool that was
|
|
27533
|
+
installed and working.
|
|
27534
|
+
|
|
27535
|
+
The defect is a layout, so the fixture builds the layout instead of relying on
|
|
27536
|
+
this host having it: a directory carrying both binaries, on a PATH between two
|
|
27537
|
+
that carry neither. Asserted from both sides — that the runtime survived is the
|
|
27538
|
+
regression guard, that the tool is gone is the positive control, because a
|
|
27539
|
+
mirror that produced an empty directory would satisfy the second alone.
|
|
27540
|
+
"""
|
|
27541
|
+
label = "a-filter-drops-only-what-it-named"
|
|
27542
|
+
|
|
27543
|
+
def write_executable(path: Path, body: str) -> None:
|
|
27544
|
+
path.write_text(body, encoding="utf-8")
|
|
27545
|
+
path.chmod(0o755)
|
|
27546
|
+
|
|
27547
|
+
with tempfile.TemporaryDirectory(prefix="keel-shared-bin-") as raw:
|
|
27548
|
+
root = Path(raw)
|
|
27549
|
+
before = root / "before"
|
|
27550
|
+
shared = root / "shared"
|
|
27551
|
+
after = root / "after"
|
|
27552
|
+
for directory in (before, shared, after):
|
|
27553
|
+
directory.mkdir()
|
|
27554
|
+
write_executable(before / "tool-a", "#!/bin/sh\necho a\n")
|
|
27555
|
+
write_executable(after / "tool-b", "#!/bin/sh\necho b\n")
|
|
27556
|
+
# The layout under test: the interpreter and the tool in one directory,
|
|
27557
|
+
# plus a neighbour that belongs to neither and must survive with it.
|
|
27558
|
+
write_executable(shared / "node", "#!/bin/sh\necho node\n")
|
|
27559
|
+
write_executable(shared / "openspec", "#!/bin/sh\necho 1.12.0\n")
|
|
27560
|
+
write_executable(shared / "openspec.cmd", "#!/bin/sh\necho 1.12.0\n")
|
|
27561
|
+
write_executable(shared / "unrelated", "#!/bin/sh\necho unrelated\n")
|
|
27562
|
+
|
|
27563
|
+
source = os.pathsep.join(str(entry) for entry in (before, shared, after))
|
|
27564
|
+
with path_without_openspec(source) as sanitized:
|
|
27565
|
+
entries = sanitized.split(os.pathsep)
|
|
27566
|
+
|
|
27567
|
+
# Behavior before shape: what the defect destroys is the ability to
|
|
27568
|
+
# run the interpreter, and an entry count is only its symptom.
|
|
27569
|
+
node = shutil.which("node", path=sanitized)
|
|
27570
|
+
if node is None:
|
|
27571
|
+
report(
|
|
27572
|
+
f"{label}: node did not survive a filter that was removing "
|
|
27573
|
+
f"openspec. The interpreter shares a directory with the "
|
|
27574
|
+
f"tool, and the whole directory went. sanitized PATH "
|
|
27575
|
+
f"{sanitized!r}"
|
|
27576
|
+
)
|
|
27577
|
+
return 1
|
|
27578
|
+
if shutil.which("unrelated", path=sanitized) is None:
|
|
27579
|
+
report(
|
|
27580
|
+
f"{label}: an executable unrelated to openspec was removed "
|
|
27581
|
+
f"along with it."
|
|
27582
|
+
)
|
|
27583
|
+
return 1
|
|
27584
|
+
|
|
27585
|
+
# The positive control. Without it, an empty mirror passes.
|
|
27586
|
+
for name in ("openspec", "openspec.cmd"):
|
|
27587
|
+
found = shutil.which(name, path=sanitized)
|
|
27588
|
+
if found is not None:
|
|
27589
|
+
report(
|
|
27590
|
+
f"{label}: {name} still resolves on a PATH built to "
|
|
27591
|
+
f"exclude it, at {found!r}."
|
|
27592
|
+
)
|
|
27593
|
+
return 1
|
|
27594
|
+
|
|
27595
|
+
if len(entries) != 3:
|
|
27596
|
+
report(
|
|
27597
|
+
f"{label}: the sanitized PATH has {len(entries)} entries, "
|
|
27598
|
+
f"not the 3 it was given, so search order did not survive. "
|
|
27599
|
+
f"got {sanitized!r}"
|
|
27600
|
+
)
|
|
27601
|
+
return 1
|
|
27602
|
+
if Path(node).parent != Path(entries[1]):
|
|
27603
|
+
report(
|
|
27604
|
+
f"{label}: node survived but moved search position; it "
|
|
27605
|
+
f"resolved from {str(Path(node).parent)!r}, not from PATH "
|
|
27606
|
+
f"entry 1 {entries[1]!r}."
|
|
27607
|
+
)
|
|
27608
|
+
return 1
|
|
27609
|
+
|
|
27610
|
+
# M2: an entry that does not hold the tool is passed through, not
|
|
27611
|
+
# mirrored — the cost and the blast radius stay on the directories
|
|
27612
|
+
# that carry it.
|
|
27613
|
+
for index, original in ((0, before), (2, after)):
|
|
27614
|
+
if entries[index] != str(original):
|
|
27615
|
+
report(
|
|
27616
|
+
f"{label}: a PATH entry holding no openspec was "
|
|
27617
|
+
f"rewritten; expected {str(original)!r}, got "
|
|
27618
|
+
f"{entries[index]!r}."
|
|
27619
|
+
)
|
|
27620
|
+
return 1
|
|
27621
|
+
|
|
27622
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
27623
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
27624
|
+
return 1
|
|
27625
|
+
report(f"{label} scenario passed.")
|
|
27626
|
+
return 0
|
|
27627
|
+
|
|
27628
|
+
|
|
27448
27629
|
SCENARIOS: tuple = (
|
|
27449
27630
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
27450
27631
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -27787,6 +27968,10 @@ SCENARIOS: tuple = (
|
|
|
27787
27968
|
"change-verify-deferred-evidence",
|
|
27788
27969
|
validate_change_verify_deferred_evidence_scenario,
|
|
27789
27970
|
),
|
|
27971
|
+
(
|
|
27972
|
+
"a-filter-drops-only-what-it-named",
|
|
27973
|
+
validate_a_filter_drops_only_what_it_named_scenario,
|
|
27974
|
+
),
|
|
27790
27975
|
)
|
|
27791
27976
|
|
|
27792
27977
|
|