@christang/keel 5.68.0 → 5.70.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.70.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.70.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",
|
|
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
38
38
|
"scripts/validate_plugin.py",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
PACKAGE_VERSION = "5.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.70.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.70.0"
|
|
43
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
44
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
45
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -2302,6 +2302,61 @@ def validate_domain_lenses_scenario() -> int:
|
|
|
2302
2302
|
return 0
|
|
2303
2303
|
|
|
2304
2304
|
|
|
2305
|
+
# `bump_version.js` writes a stub section for the new release, and the convention
|
|
2306
|
+
# is to fill it in. Writing the entry *above* it instead leaves an orphan, and
|
|
2307
|
+
# the stub carries the `Version alignment:` line this scenario reads — so the
|
|
2308
|
+
# section describing nothing was the section that passed (#151). It happened in
|
|
2309
|
+
# 5.67.0 and 5.68.0, with the suite green both times.
|
|
2310
|
+
#
|
|
2311
|
+
# Takes the text rather than reading the file, so the rule can be exercised on a
|
|
2312
|
+
# planted copy: a rule that could only ever see the changelog already known to be
|
|
2313
|
+
# correct would pass forever without anyone learning whether it fires.
|
|
2314
|
+
def release_description_problem(changelog: str, version: str) -> str | None:
|
|
2315
|
+
"""Return the problem the released version's section has, or None.
|
|
2316
|
+
|
|
2317
|
+
Scoped to the version being released. An older section is history a current
|
|
2318
|
+
author cannot act on, and a rule that swept the whole file would fail on the
|
|
2319
|
+
archive — which is how a check gets disabled rather than fixed. The file also
|
|
2320
|
+
legitimately quotes `TODO` when describing the rules that refuse it.
|
|
2321
|
+
"""
|
|
2322
|
+
pattern = re.compile(
|
|
2323
|
+
r"^## " + re.escape(version) + r"\b[^\n]*$", re.M
|
|
2324
|
+
)
|
|
2325
|
+
headings = pattern.findall(changelog)
|
|
2326
|
+
if not headings:
|
|
2327
|
+
return (
|
|
2328
|
+
f"the changelog carries no `## {version}` section, so the release "
|
|
2329
|
+
"describes nothing at all."
|
|
2330
|
+
)
|
|
2331
|
+
if len(headings) > 1:
|
|
2332
|
+
return (
|
|
2333
|
+
f"the changelog describes {version} twice — {len(headings)} `## "
|
|
2334
|
+
f"{version}` headings: {', '.join(h.strip() for h in headings)}. A "
|
|
2335
|
+
"release entry written above the stub instead of into it leaves both."
|
|
2336
|
+
)
|
|
2337
|
+
for heading in headings:
|
|
2338
|
+
if "TODO" in heading:
|
|
2339
|
+
return (
|
|
2340
|
+
f"the changelog still carries the stub heading `{heading.strip()}`"
|
|
2341
|
+
f", so the {version} release was never described. Fill that "
|
|
2342
|
+
"section in rather than writing the entry above it."
|
|
2343
|
+
)
|
|
2344
|
+
# The section body, bounded by the next `## ` heading, so a `TODO` belonging
|
|
2345
|
+
# to an older release is not read as this one's.
|
|
2346
|
+
start = changelog.index(headings[0])
|
|
2347
|
+
rest = changelog[start + len(headings[0]):]
|
|
2348
|
+
end = rest.find("\n## ")
|
|
2349
|
+
body = rest if end < 0 else rest[:end]
|
|
2350
|
+
for line in body.splitlines():
|
|
2351
|
+
if re.match(r"^\s*-\s*TODO\b", line):
|
|
2352
|
+
return (
|
|
2353
|
+
f"the {version} section still carries the stub bullet "
|
|
2354
|
+
f"`{line.strip().lstrip('- ')}`, so the release is titled but "
|
|
2355
|
+
"not described."
|
|
2356
|
+
)
|
|
2357
|
+
return None
|
|
2358
|
+
|
|
2359
|
+
|
|
2305
2360
|
def validate_version_alignment_scenario() -> int:
|
|
2306
2361
|
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
|
2307
2362
|
if package.get("version") != PACKAGE_VERSION:
|
|
@@ -2366,6 +2421,103 @@ def validate_version_alignment_scenario() -> int:
|
|
|
2366
2421
|
)
|
|
2367
2422
|
return 1
|
|
2368
2423
|
|
|
2424
|
+
# The released version's section has to say what the release did. Asserted on
|
|
2425
|
+
# planted copies first, so the rule is known to fire, and then on the
|
|
2426
|
+
# repository's own changelog.
|
|
2427
|
+
stub = (
|
|
2428
|
+
f"# Keel Changelog\n\n## {PACKAGE_VERSION} - TODO: summarize this "
|
|
2429
|
+
f"release\n\n- TODO: describe the change.\n- Version alignment: "
|
|
2430
|
+
f"shares Keel {PACKAGE_VERSION}.\n\n## 5.0.0 - older\n\n- Done.\n"
|
|
2431
|
+
)
|
|
2432
|
+
planted = release_description_problem(stub, PACKAGE_VERSION)
|
|
2433
|
+
if not planted:
|
|
2434
|
+
report(
|
|
2435
|
+
"version-alignment scenario: an unfilled stub was accepted — a "
|
|
2436
|
+
f"changelog whose {PACKAGE_VERSION} section is the stub "
|
|
2437
|
+
"`bump_version.js` writes produced no problem, so a release can "
|
|
2438
|
+
"ship describing nothing while this scenario passes."
|
|
2439
|
+
)
|
|
2440
|
+
return 1
|
|
2441
|
+
if "TODO: summarize this release" not in planted:
|
|
2442
|
+
report(
|
|
2443
|
+
"version-alignment scenario: the refusal does not name the offending "
|
|
2444
|
+
f"line, so an author is sent to read the whole changelog; got "
|
|
2445
|
+
f"{planted!r}."
|
|
2446
|
+
)
|
|
2447
|
+
return 1
|
|
2448
|
+
|
|
2449
|
+
# Still M1 — the stub survives renaming its heading. The bullet is the other
|
|
2450
|
+
# half of what `bump_version.js` writes, and a section titled correctly while
|
|
2451
|
+
# its only content is `- TODO: describe the change.` describes nothing.
|
|
2452
|
+
renamed = (
|
|
2453
|
+
f"# Keel Changelog\n\n## {PACKAGE_VERSION} - a real sounding title\n\n"
|
|
2454
|
+
f"- TODO: describe the change.\n- Version alignment: shares Keel "
|
|
2455
|
+
f"{PACKAGE_VERSION}.\n"
|
|
2456
|
+
)
|
|
2457
|
+
bullet = release_description_problem(renamed, PACKAGE_VERSION)
|
|
2458
|
+
if not bullet:
|
|
2459
|
+
report(
|
|
2460
|
+
"version-alignment scenario: an unfilled stub was accepted — a "
|
|
2461
|
+
"section with a real heading whose content is still `- TODO: describe "
|
|
2462
|
+
"the change.` produced no problem, so renaming the stub is enough to "
|
|
2463
|
+
"ship an undescribed release."
|
|
2464
|
+
)
|
|
2465
|
+
return 1
|
|
2466
|
+
if "TODO: describe the change." not in bullet:
|
|
2467
|
+
report(
|
|
2468
|
+
"version-alignment scenario: the bullet refusal does not name the "
|
|
2469
|
+
f"line it found; got {bullet!r}."
|
|
2470
|
+
)
|
|
2471
|
+
return 1
|
|
2472
|
+
|
|
2473
|
+
# M2 — two sections for one version. This is the shape the real defect had,
|
|
2474
|
+
# and neither TODO rule catches it once the stub's heading has been renamed:
|
|
2475
|
+
# the reader then sees two identical version headings, one of which describes
|
|
2476
|
+
# nothing.
|
|
2477
|
+
doubled = (
|
|
2478
|
+
f"# Keel Changelog\n\n## {PACKAGE_VERSION} - the real entry\n\n"
|
|
2479
|
+
f"- Did the thing.\n- Version alignment: shares Keel {PACKAGE_VERSION}."
|
|
2480
|
+
f"\n\n## {PACKAGE_VERSION} - summarized\n\n- Also this.\n"
|
|
2481
|
+
)
|
|
2482
|
+
problem_found = release_description_problem(doubled, PACKAGE_VERSION)
|
|
2483
|
+
if not problem_found:
|
|
2484
|
+
report(
|
|
2485
|
+
"version-alignment scenario: a version described twice was accepted "
|
|
2486
|
+
f"— two `## {PACKAGE_VERSION}` sections produced no problem, which is "
|
|
2487
|
+
"the shape the defect this rule exists for actually had."
|
|
2488
|
+
)
|
|
2489
|
+
return 1
|
|
2490
|
+
if "twice" not in problem_found:
|
|
2491
|
+
report(
|
|
2492
|
+
"version-alignment scenario: the duplicate-section refusal does not "
|
|
2493
|
+
f"say the version is described twice; got {problem_found!r}."
|
|
2494
|
+
)
|
|
2495
|
+
return 1
|
|
2496
|
+
|
|
2497
|
+
# M3 — only the version being released is judged. An older section is history,
|
|
2498
|
+
# and this file legitimately quotes `TODO` when describing the rules that
|
|
2499
|
+
# refuse it, so a rule sweeping the whole file would fire on its own
|
|
2500
|
+
# documentation.
|
|
2501
|
+
older = (
|
|
2502
|
+
f"# Keel Changelog\n\n## {PACKAGE_VERSION} - the real entry\n\n"
|
|
2503
|
+
f"- Did the thing.\n- Version alignment: shares Keel {PACKAGE_VERSION}."
|
|
2504
|
+
"\n\n## 5.0.0 - TODO: summarize this release\n\n- TODO: describe it.\n"
|
|
2505
|
+
)
|
|
2506
|
+
if release_description_problem(older, PACKAGE_VERSION):
|
|
2507
|
+
report(
|
|
2508
|
+
"version-alignment scenario: an older section was refused — a `TODO` "
|
|
2509
|
+
"in a section for another version failed the current release, which "
|
|
2510
|
+
"is a check the archive can only be edited to satisfy."
|
|
2511
|
+
)
|
|
2512
|
+
return 1
|
|
2513
|
+
if release_description_problem(changelog, PACKAGE_VERSION):
|
|
2514
|
+
report(
|
|
2515
|
+
"version-alignment scenario: an older section was refused — the "
|
|
2516
|
+
"repository's own changelog does not satisfy the rule: "
|
|
2517
|
+
f"{release_description_problem(changelog, PACKAGE_VERSION)}"
|
|
2518
|
+
)
|
|
2519
|
+
return 1
|
|
2520
|
+
|
|
2369
2521
|
with tempfile.TemporaryDirectory(prefix="keel-version-") as raw_tmp:
|
|
2370
2522
|
repo = Path(raw_tmp) / "repo"
|
|
2371
2523
|
repo.mkdir()
|
|
@@ -29545,6 +29697,138 @@ def validate_an_equivalence_claim_names_its_base_scenario() -> int:
|
|
|
29545
29697
|
return 0
|
|
29546
29698
|
|
|
29547
29699
|
|
|
29700
|
+
# The workflow serializes, or it does not. Asserted by reading the file, because
|
|
29701
|
+
# it runs only on GitHub, only on a `release` event, and nothing local can
|
|
29702
|
+
# exercise it — which is exactly why an edit could drop the declaration and
|
|
29703
|
+
# nobody would find out until the next day someone cut several releases at once.
|
|
29704
|
+
PUBLISH_WORKFLOW = ".github/workflows/publish.yml"
|
|
29705
|
+
|
|
29706
|
+
|
|
29707
|
+
def publish_serialization_problem(workflow: str) -> str | None:
|
|
29708
|
+
"""Return the problem the publish workflow's ordering has, or None.
|
|
29709
|
+
|
|
29710
|
+
Takes the text rather than reading the path, so each broken shape can be
|
|
29711
|
+
exercised on a planted copy. A rule that could only ever see the file already
|
|
29712
|
+
known to be correct would pass forever without anyone learning whether it
|
|
29713
|
+
fires.
|
|
29714
|
+
"""
|
|
29715
|
+
block = re.search(
|
|
29716
|
+
r"^concurrency:\n((?:[ \t]+\S[^\n]*\n)+)", workflow, re.M
|
|
29717
|
+
)
|
|
29718
|
+
if not block:
|
|
29719
|
+
return (
|
|
29720
|
+
"simultaneous releases publish concurrently — "
|
|
29721
|
+
f"{PUBLISH_WORKFLOW} declares no `concurrency:` group, so every "
|
|
29722
|
+
"release event starts its own publish against the same package."
|
|
29723
|
+
)
|
|
29724
|
+
body = block.group(1)
|
|
29725
|
+
if not re.search(r"^[ \t]+cancel-in-progress:\s*false\s*$", body, re.M):
|
|
29726
|
+
return (
|
|
29727
|
+
"a queued publish would be cancelled — the group declares no "
|
|
29728
|
+
"`cancel-in-progress: false`, and the default is `true`: the next "
|
|
29729
|
+
"release would cancel a publish still waiting to run, losing that "
|
|
29730
|
+
"version outright rather than appearing to."
|
|
29731
|
+
)
|
|
29732
|
+
group = re.search(r"^[ \t]+group:\s*(\S.*?)\s*$", body, re.M)
|
|
29733
|
+
if not group:
|
|
29734
|
+
return (
|
|
29735
|
+
"a per-run group serializes nothing — the `concurrency:` block "
|
|
29736
|
+
"names no `group:`, so there is nothing for a run to queue behind."
|
|
29737
|
+
)
|
|
29738
|
+
if "${{" in group.group(1):
|
|
29739
|
+
return (
|
|
29740
|
+
"a per-run group serializes nothing — the group expression "
|
|
29741
|
+
f"`{group.group(1)}` is evaluated per run, so each release gets a "
|
|
29742
|
+
"group of its own and none of them wait. The thing being protected "
|
|
29743
|
+
"is the package, and there is one of those."
|
|
29744
|
+
)
|
|
29745
|
+
return None
|
|
29746
|
+
|
|
29747
|
+
|
|
29748
|
+
def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
|
|
29749
|
+
"""Issue #153: eleven releases at once, and a registry that lied about it.
|
|
29750
|
+
|
|
29751
|
+
Nothing was lost — every job logged its own `+ @christang/keel@<version>`,
|
|
29752
|
+
and the write side proved it by refusing a re-run with `You cannot publish
|
|
29753
|
+
over the previously published versions`. What broke was the read side: the
|
|
29754
|
+
packument reported published versions as missing, and the set changed
|
|
29755
|
+
between reads. Two re-runs were triggered on that false reading and are now
|
|
29756
|
+
red rows against a release that succeeded. Serialized, each publish finishes
|
|
29757
|
+
before the next begins and the window does not exist.
|
|
29758
|
+
"""
|
|
29759
|
+
label = "a-publish-waits-for-the-one-before-it"
|
|
29760
|
+
path = ROOT / PUBLISH_WORKFLOW
|
|
29761
|
+
if not path.is_file():
|
|
29762
|
+
report(f"{label}: {PUBLISH_WORKFLOW} is missing.")
|
|
29763
|
+
return 1
|
|
29764
|
+
workflow = path.read_text(encoding="utf-8")
|
|
29765
|
+
|
|
29766
|
+
# M1 — the real file declares it, and a copy with the block removed does not.
|
|
29767
|
+
# The removal is the state the file was actually in on 2026-09-25.
|
|
29768
|
+
problem = publish_serialization_problem(workflow)
|
|
29769
|
+
if problem:
|
|
29770
|
+
report(f"{label}: {problem}")
|
|
29771
|
+
return 1
|
|
29772
|
+
stripped = re.sub(
|
|
29773
|
+
r"^concurrency:\n(?:[ \t]+\S[^\n]*\n)+", "", workflow, flags=re.M
|
|
29774
|
+
)
|
|
29775
|
+
if stripped == workflow:
|
|
29776
|
+
report(
|
|
29777
|
+
f"{label}: simultaneous releases publish concurrently — no "
|
|
29778
|
+
"`concurrency:` block could be located to remove, so the real file "
|
|
29779
|
+
"does not declare one and the check has nothing to prove."
|
|
29780
|
+
)
|
|
29781
|
+
return 1
|
|
29782
|
+
if not publish_serialization_problem(stripped):
|
|
29783
|
+
report(
|
|
29784
|
+
f"{label}: simultaneous releases publish concurrently — a workflow "
|
|
29785
|
+
"with no concurrency group was accepted, which is the configuration "
|
|
29786
|
+
"that let eleven releases publish at once."
|
|
29787
|
+
)
|
|
29788
|
+
return 1
|
|
29789
|
+
|
|
29790
|
+
# M2 — the option whose default is wrong for this job. A check satisfied by
|
|
29791
|
+
# the block alone would pass on the configuration that loses a version
|
|
29792
|
+
# outright, which is worse than the one being fixed here.
|
|
29793
|
+
cancelling = workflow.replace(" cancel-in-progress: false\n", "")
|
|
29794
|
+
if cancelling == workflow:
|
|
29795
|
+
report(
|
|
29796
|
+
f"{label}: a queued publish would be cancelled — "
|
|
29797
|
+
"`cancel-in-progress: false` could not be located to remove, so the "
|
|
29798
|
+
"real file does not declare it."
|
|
29799
|
+
)
|
|
29800
|
+
return 1
|
|
29801
|
+
if not publish_serialization_problem(cancelling):
|
|
29802
|
+
report(
|
|
29803
|
+
f"{label}: a queued publish would be cancelled — a group without "
|
|
29804
|
+
"`cancel-in-progress: false` was accepted, and the default cancels "
|
|
29805
|
+
"a queued publish when the next release fires."
|
|
29806
|
+
)
|
|
29807
|
+
return 1
|
|
29808
|
+
|
|
29809
|
+
# M3 — the shape that looks like a fix and is not. A group keyed per release
|
|
29810
|
+
# gives every run its own group, so nothing ever queues behind anything.
|
|
29811
|
+
per_ref = workflow.replace(
|
|
29812
|
+
" group: publish\n", " group: publish-${{ github.ref }}\n"
|
|
29813
|
+
)
|
|
29814
|
+
if per_ref == workflow:
|
|
29815
|
+
report(f"{label}: a per-run group serializes nothing — `group: publish` could not be located to vary.")
|
|
29816
|
+
return 1
|
|
29817
|
+
if not publish_serialization_problem(per_ref):
|
|
29818
|
+
report(
|
|
29819
|
+
f"{label}: a per-run group serializes nothing — a group expression "
|
|
29820
|
+
"that differs per release was accepted, so each publish gets a group "
|
|
29821
|
+
"of its own and none of them wait."
|
|
29822
|
+
)
|
|
29823
|
+
return 1
|
|
29824
|
+
|
|
29825
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
29826
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
29827
|
+
return 1
|
|
29828
|
+
report(f"{label} scenario passed.")
|
|
29829
|
+
return 0
|
|
29830
|
+
|
|
29831
|
+
|
|
29548
29832
|
SCENARIOS: tuple = (
|
|
29549
29833
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
29550
29834
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -29911,6 +30195,10 @@ SCENARIOS: tuple = (
|
|
|
29911
30195
|
"an-equivalence-claim-names-its-base",
|
|
29912
30196
|
validate_an_equivalence_claim_names_its_base_scenario,
|
|
29913
30197
|
),
|
|
30198
|
+
(
|
|
30199
|
+
"a-publish-waits-for-the-one-before-it",
|
|
30200
|
+
validate_a_publish_waits_for_the_one_before_it_scenario,
|
|
30201
|
+
),
|
|
29914
30202
|
(
|
|
29915
30203
|
"guidance-is-referenced-and-carries-no-criterion",
|
|
29916
30204
|
validate_guidance_is_referenced_and_carries_no_criterion_scenario,
|