@christang/keel 5.44.0 → 5.46.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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@christang/keel",
|
|
3
3
|
"displayName": "Keel",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
|
|
5
|
-
"version": "5.
|
|
5
|
+
"version": "5.46.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"src/core/",
|
|
18
18
|
"assets/",
|
|
19
19
|
"README.md",
|
|
20
|
-
"plugins/"
|
|
20
|
+
"plugins/",
|
|
21
|
+
"!**/__pycache__",
|
|
22
|
+
"!**/*.pyc"
|
|
21
23
|
],
|
|
22
24
|
"scripts": {
|
|
23
25
|
"validate": "node scripts/run_python.js scripts/validate_plugin.py",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.46.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.46.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",
|
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
40
|
+
PACKAGE_VERSION = "5.46.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.46.0"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -5052,7 +5052,10 @@ def validate_authoring_surface_owner_and_tags_scenario() -> int:
|
|
|
5052
5052
|
return 1
|
|
5053
5053
|
created = run_openspec(repo, "new", "change", "surface-probe")
|
|
5054
5054
|
if created is None:
|
|
5055
|
-
report(
|
|
5055
|
+
report(
|
|
5056
|
+
f"{label} skipped: the openspec CLI could not be found. "
|
|
5057
|
+
"Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
|
|
5058
|
+
)
|
|
5056
5059
|
return 3
|
|
5057
5060
|
if created.returncode != 0:
|
|
5058
5061
|
report(f"{label} could not scaffold a change to read the instruction.")
|
|
@@ -5268,6 +5271,466 @@ def validate_the_spec_names_the_managed_set_scenario() -> int:
|
|
|
5268
5271
|
return 0
|
|
5269
5272
|
|
|
5270
5273
|
|
|
5274
|
+
def validate_an_invalidates_phrase_may_wrap_scenario() -> int:
|
|
5275
|
+
"""Issue #108: the quoted phrase had to fit on one line.
|
|
5276
|
+
|
|
5277
|
+
An `## Invalidates` entry carries a quotation, a location, and a closure,
|
|
5278
|
+
and wraps as often as it needs to. The phrase test was
|
|
5279
|
+
`/"[^"\\n]{3,}"/`, so a quotation that ran past the end of a line was
|
|
5280
|
+
reported as `names where to look but not what to look for` — when it named
|
|
5281
|
+
exactly that, and the only repair was to reflow the text.
|
|
5282
|
+
|
|
5283
|
+
The corpus looked clean for the wrong reason: 42 of this repository's 194
|
|
5284
|
+
archived entries span more than one line and none carries a wrapped
|
|
5285
|
+
quotation, because the gate refused every one that did. Five entries in a
|
|
5286
|
+
single session were shortened to fit.
|
|
5287
|
+
|
|
5288
|
+
The bound is the entry, set by the section parser, so a quotation cannot
|
|
5289
|
+
reach past its own entry — asserted below by refusing a second, unquoted
|
|
5290
|
+
entry while the wrapped one is accepted.
|
|
5291
|
+
"""
|
|
5292
|
+
label = "an-invalidates-phrase-may-wrap"
|
|
5293
|
+
|
|
5294
|
+
with tempfile.TemporaryDirectory(prefix="keel-wrapped-phrase-") as raw:
|
|
5295
|
+
repo = Path(raw) / "repo"
|
|
5296
|
+
repo.mkdir()
|
|
5297
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
5298
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
5299
|
+
write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
|
|
5300
|
+
write_text(
|
|
5301
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
5302
|
+
"## ADDED Requirements\n",
|
|
5303
|
+
)
|
|
5304
|
+
|
|
5305
|
+
def start(section: str) -> dict:
|
|
5306
|
+
write_text(
|
|
5307
|
+
tasks_path,
|
|
5308
|
+
task_contract_fixture().replace(
|
|
5309
|
+
"## Invalidates\n\n- None.\n\n", section
|
|
5310
|
+
),
|
|
5311
|
+
)
|
|
5312
|
+
result = run_keel(
|
|
5313
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
5314
|
+
"--json",
|
|
5315
|
+
)
|
|
5316
|
+
return json.loads(result.stdout)
|
|
5317
|
+
|
|
5318
|
+
def problems(payload: dict) -> str:
|
|
5319
|
+
return " ".join(
|
|
5320
|
+
item.get("code", "") + ": " + item.get("message", "")
|
|
5321
|
+
for item in payload.get("problems", [])
|
|
5322
|
+
)
|
|
5323
|
+
|
|
5324
|
+
wrapped = (
|
|
5325
|
+
'## Invalidates\n\n'
|
|
5326
|
+
'- I1: "the wording that is now wrong and runs on\n'
|
|
5327
|
+
' past the end of this line" — somewhere in the repo.\n'
|
|
5328
|
+
' Updated by: 1.1\n\n'
|
|
5329
|
+
)
|
|
5330
|
+
payload = start(wrapped)
|
|
5331
|
+
if payload.get("status") != "pass":
|
|
5332
|
+
report(
|
|
5333
|
+
f"{label}: an entry whose quotation wraps was refused. It named "
|
|
5334
|
+
"the wording a reader would search for; what it did not do was "
|
|
5335
|
+
"fit on one line, and reflowing text is not a repair."
|
|
5336
|
+
)
|
|
5337
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5338
|
+
return 1
|
|
5339
|
+
|
|
5340
|
+
# The requirement itself is unchanged: an entry with no quotation
|
|
5341
|
+
# anywhere in its body is still refused.
|
|
5342
|
+
unquoted = (
|
|
5343
|
+
'## Invalidates\n\n'
|
|
5344
|
+
'- I1: the wording that is now wrong and runs on\n'
|
|
5345
|
+
' past the end of this line — somewhere in the repo.\n'
|
|
5346
|
+
' Updated by: 1.1\n\n'
|
|
5347
|
+
)
|
|
5348
|
+
payload = start(unquoted)
|
|
5349
|
+
if payload.get("status") == "pass":
|
|
5350
|
+
report(
|
|
5351
|
+
f"{label}: an entry carrying no quotation at all was accepted. "
|
|
5352
|
+
"Reading the phrase across lines must widen where the quotation "
|
|
5353
|
+
"may sit, not remove the requirement to write one."
|
|
5354
|
+
)
|
|
5355
|
+
return 1
|
|
5356
|
+
if "invalidation-phrase" not in problems(payload):
|
|
5357
|
+
report(
|
|
5358
|
+
f"{label}: the unquoted entry was refused for some other reason, "
|
|
5359
|
+
"so this assertion proves nothing about the phrase check. Got: "
|
|
5360
|
+
+ problems(payload)
|
|
5361
|
+
)
|
|
5362
|
+
return 1
|
|
5363
|
+
|
|
5364
|
+
# The bound is the entry. A wrapped quotation in I1 must not satisfy an
|
|
5365
|
+
# unquoted I2 sitting below it.
|
|
5366
|
+
mixed = (
|
|
5367
|
+
'## Invalidates\n\n'
|
|
5368
|
+
'- I1: "the wording that is now wrong and runs on\n'
|
|
5369
|
+
' past the end of this line" — somewhere in the repo.\n'
|
|
5370
|
+
' Updated by: 1.1\n'
|
|
5371
|
+
'- I2: some other statement — elsewhere in the repo.\n'
|
|
5372
|
+
' Updated by: 1.1\n\n'
|
|
5373
|
+
)
|
|
5374
|
+
payload = start(mixed)
|
|
5375
|
+
text = problems(payload)
|
|
5376
|
+
if payload.get("status") == "pass":
|
|
5377
|
+
report(
|
|
5378
|
+
f"{label}: an unquoted entry below a wrapped one was accepted. "
|
|
5379
|
+
"The quotation is bounded by its own entry; if it reaches the "
|
|
5380
|
+
"next one, every entry after a quoted one passes for free."
|
|
5381
|
+
)
|
|
5382
|
+
return 1
|
|
5383
|
+
if "I2" not in text:
|
|
5384
|
+
report(
|
|
5385
|
+
f"{label}: the run failed, but not for I2 — so this assertion "
|
|
5386
|
+
"proves nothing about the entry bound. The unquoted entry is "
|
|
5387
|
+
"the one that must be named. Got: " + text
|
|
5388
|
+
)
|
|
5389
|
+
return 1
|
|
5390
|
+
if "I1" in text:
|
|
5391
|
+
report(
|
|
5392
|
+
f"{label}: the wrapped entry was reported alongside the unquoted "
|
|
5393
|
+
"one. It carries its quotation and must not be named."
|
|
5394
|
+
)
|
|
5395
|
+
report(text)
|
|
5396
|
+
return 1
|
|
5397
|
+
|
|
5398
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5399
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5400
|
+
return 1
|
|
5401
|
+
report(f"{label} scenario passed.")
|
|
5402
|
+
return 0
|
|
5403
|
+
|
|
5404
|
+
|
|
5405
|
+
def validate_the_tarball_is_the_repository_scenario() -> int:
|
|
5406
|
+
"""Issue #110: what ships depended on the packer's working tree.
|
|
5407
|
+
|
|
5408
|
+
`files` names `scripts/`, and declaring a `files` array means `.gitignore`
|
|
5409
|
+
stops filtering inside it — while `__pycache__` is not on npm's default
|
|
5410
|
+
exclusion list. Packing one commit twice: 41 files on a clean checkout, 43
|
|
5411
|
+
after anyone has run the Python in `scripts/`.
|
|
5412
|
+
|
|
5413
|
+
The assertion is that every packed file is tracked by Git. That states the
|
|
5414
|
+
requirement directly and cannot be satisfied by a machine's leftovers; a
|
|
5415
|
+
recomputed list of expected files would have to reimplement npm's inclusion
|
|
5416
|
+
rules and would drift from them.
|
|
5417
|
+
"""
|
|
5418
|
+
label = "the-tarball-is-the-repository"
|
|
5419
|
+
|
|
5420
|
+
npm = shutil.which("npm")
|
|
5421
|
+
if npm is None:
|
|
5422
|
+
report(f"{label} skipped: npm is not on PATH, and it is what packs.")
|
|
5423
|
+
return 3
|
|
5424
|
+
|
|
5425
|
+
# Reproduce the state the issue reports before asserting anything, so the
|
|
5426
|
+
# check runs against a tree that has the residue rather than one that
|
|
5427
|
+
# happens not to.
|
|
5428
|
+
residue = ROOT / "scripts" / "__pycache__" / "keel-pack-probe.pyc"
|
|
5429
|
+
created_dir = not residue.parent.exists()
|
|
5430
|
+
residue.parent.mkdir(parents=True, exist_ok=True)
|
|
5431
|
+
residue.write_bytes(b"probe\n")
|
|
5432
|
+
try:
|
|
5433
|
+
packed = subprocess.run(
|
|
5434
|
+
[npm, "pack", "--dry-run", "--json"],
|
|
5435
|
+
cwd=ROOT,
|
|
5436
|
+
text=True,
|
|
5437
|
+
encoding="utf-8",
|
|
5438
|
+
errors="replace",
|
|
5439
|
+
capture_output=True,
|
|
5440
|
+
check=False,
|
|
5441
|
+
)
|
|
5442
|
+
if packed.returncode != 0:
|
|
5443
|
+
report(f"{label}: npm pack --dry-run failed.")
|
|
5444
|
+
report((packed.stderr or packed.stdout).strip())
|
|
5445
|
+
return 1
|
|
5446
|
+
try:
|
|
5447
|
+
files = [item["path"] for item in json.loads(packed.stdout)[0]["files"]]
|
|
5448
|
+
except (ValueError, KeyError, IndexError):
|
|
5449
|
+
report(f"{label}: could not read the file list from npm pack --json.")
|
|
5450
|
+
report((packed.stdout or "").strip()[:400])
|
|
5451
|
+
return 1
|
|
5452
|
+
|
|
5453
|
+
tracked = subprocess.run(
|
|
5454
|
+
["git", "ls-files", "-z"],
|
|
5455
|
+
cwd=ROOT,
|
|
5456
|
+
capture_output=True,
|
|
5457
|
+
check=False,
|
|
5458
|
+
)
|
|
5459
|
+
if tracked.returncode != 0:
|
|
5460
|
+
report(f"{label}: git ls-files failed, so tracked state is unknown.")
|
|
5461
|
+
return 1
|
|
5462
|
+
known = {
|
|
5463
|
+
name for name in tracked.stdout.decode("utf-8").split("\0") if name
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
untracked = sorted(path for path in files if path not in known)
|
|
5467
|
+
if untracked:
|
|
5468
|
+
report(
|
|
5469
|
+
f"{label}: {len(untracked)} packed file(s) are not tracked by "
|
|
5470
|
+
"Git, so what ships depends on the machine that packs it "
|
|
5471
|
+
"rather than on the repository:"
|
|
5472
|
+
)
|
|
5473
|
+
for path in untracked:
|
|
5474
|
+
report(f" {path}")
|
|
5475
|
+
return 1
|
|
5476
|
+
if not files:
|
|
5477
|
+
report(f"{label}: npm pack reported no files, so nothing was checked.")
|
|
5478
|
+
return 1
|
|
5479
|
+
finally:
|
|
5480
|
+
residue.unlink(missing_ok=True)
|
|
5481
|
+
if created_dir:
|
|
5482
|
+
try:
|
|
5483
|
+
residue.parent.rmdir()
|
|
5484
|
+
except OSError:
|
|
5485
|
+
pass
|
|
5486
|
+
|
|
5487
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5488
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5489
|
+
return 1
|
|
5490
|
+
report(f"{label} scenario passed: {len(files)} packed files, all tracked.")
|
|
5491
|
+
return 0
|
|
5492
|
+
|
|
5493
|
+
|
|
5494
|
+
def validate_a_declared_dependency_is_resolved_scenario() -> int:
|
|
5495
|
+
"""Issue #105: the suite looked for its own dependency only on PATH.
|
|
5496
|
+
|
|
5497
|
+
`@fission-ai/openspec` is declared in `package.json` and lands at
|
|
5498
|
+
`node_modules/.bin/openspec`. npm scripts see that directory; a Python
|
|
5499
|
+
subprocess started by `node scripts/run_python.js` does not. So on a
|
|
5500
|
+
checkout that had only run `npm install`, `--all` reported
|
|
5501
|
+
`validation --all failed for: compact-task-authoring` while the schema it
|
|
5502
|
+
was said to be unable to resolve resolved perfectly.
|
|
5503
|
+
|
|
5504
|
+
The second half is that scenario's own condition: an unresolvable CLI and a
|
|
5505
|
+
CLI that ran and refused shared one message, so the reader was sent to a
|
|
5506
|
+
subject with nothing wrong with it.
|
|
5507
|
+
"""
|
|
5508
|
+
label = "a-declared-dependency-is-resolved"
|
|
5509
|
+
|
|
5510
|
+
# `no openspec on PATH`, and nothing else removed. Emptying PATH outright
|
|
5511
|
+
# would also remove `node`, and the openspec shim needs it — the scenario
|
|
5512
|
+
# would then be asserting that a shell without an interpreter fails.
|
|
5513
|
+
without = dict(os.environ)
|
|
5514
|
+
without["PATH"] = os.pathsep.join(
|
|
5515
|
+
entry
|
|
5516
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep)
|
|
5517
|
+
if entry and not (Path(entry) / "openspec").exists()
|
|
5518
|
+
)
|
|
5519
|
+
if shutil.which("openspec", path=without["PATH"]) is not None:
|
|
5520
|
+
report(
|
|
5521
|
+
f"{label}: could not build a PATH without openspec on it, so the "
|
|
5522
|
+
"reproduction cannot be set up."
|
|
5523
|
+
)
|
|
5524
|
+
return 1
|
|
5525
|
+
resolved = run_openspec(ROOT, "--version", env=without)
|
|
5526
|
+
if resolved is None:
|
|
5527
|
+
report(
|
|
5528
|
+
f"{label}: with no `openspec` on PATH the runner resolved nothing, "
|
|
5529
|
+
"but this package declares it as a dependency and installs it at "
|
|
5530
|
+
"node_modules/.bin. A tool the repository ships is not a tool the "
|
|
5531
|
+
"host has to provide."
|
|
5532
|
+
)
|
|
5533
|
+
return 1
|
|
5534
|
+
if resolved.returncode != 0 or not re.search(r"\d+\.\d+\.\d+", resolved.stdout):
|
|
5535
|
+
report(
|
|
5536
|
+
f"{label}: the resolved openspec did not report a version. "
|
|
5537
|
+
f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
|
|
5538
|
+
)
|
|
5539
|
+
return 1
|
|
5540
|
+
|
|
5541
|
+
# And it is the declared one, not whatever a host happens to carry.
|
|
5542
|
+
declared = ROOT / "node_modules" / ".bin" / "openspec"
|
|
5543
|
+
if not declared.exists():
|
|
5544
|
+
report(
|
|
5545
|
+
f"{label}: {declared} is absent, so this scenario cannot tell a "
|
|
5546
|
+
"resolved dependency from a lucky PATH entry. Run `npm install`."
|
|
5547
|
+
)
|
|
5548
|
+
return 3
|
|
5549
|
+
|
|
5550
|
+
# The two failures carry their own messages. Asserted on the emitted text
|
|
5551
|
+
# of the scenario that had them fused, not on the source.
|
|
5552
|
+
fused = (ROOT / "scripts" / "validate_plugin.py").read_text(encoding="utf-8")
|
|
5553
|
+
marker = 'label = "compact-task-authoring"'
|
|
5554
|
+
start = fused.find(marker)
|
|
5555
|
+
if start < 0:
|
|
5556
|
+
report(f"{label}: compact-task-authoring is gone, so its branch cannot be read.")
|
|
5557
|
+
return 1
|
|
5558
|
+
body = fused[start : start + 3000]
|
|
5559
|
+
for needle, why in (
|
|
5560
|
+
("could not be found", "the unresolvable branch must say the tool was not found"),
|
|
5561
|
+
("searched", "the unresolvable branch must name where it looked"),
|
|
5562
|
+
("return 3", "an unresolvable tool reports the skip contract, not a failure"),
|
|
5563
|
+
):
|
|
5564
|
+
if needle not in body:
|
|
5565
|
+
report(f"{label}: {why}; `{needle}` is absent from its branch.")
|
|
5566
|
+
return 1
|
|
5567
|
+
if re.search(r"if which is None or which\.returncode", body):
|
|
5568
|
+
report(
|
|
5569
|
+
f"{label}: compact-task-authoring still guards two distinct "
|
|
5570
|
+
"failures behind one condition. A tool that was never found and a "
|
|
5571
|
+
"tool that ran and refused are different facts, and reporting the "
|
|
5572
|
+
"first message when the second happened names the wrong cause."
|
|
5573
|
+
)
|
|
5574
|
+
return 1
|
|
5575
|
+
|
|
5576
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5577
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5578
|
+
return 1
|
|
5579
|
+
report(f"{label} scenario passed.")
|
|
5580
|
+
return 0
|
|
5581
|
+
|
|
5582
|
+
|
|
5583
|
+
def validate_a_root_file_is_a_path_scenario() -> int:
|
|
5584
|
+
"""Issue #107: a file at the repository root had no separator to find.
|
|
5585
|
+
|
|
5586
|
+
`declaredPath()` located a path by finding a run of non-whitespace holding
|
|
5587
|
+
a path separator. Nine files sit at this repository's root and every one of
|
|
5588
|
+
them is a legitimate owner, so `Durable owner: AGENTS.md` was refused with
|
|
5589
|
+
`it names neither a check nor a path` — for a path whose file exists. The
|
|
5590
|
+
way past it was `./AGENTS.md`, which is a concession to the extractor
|
|
5591
|
+
rather than a path anyone meant.
|
|
5592
|
+
|
|
5593
|
+
The boundary is what keeps this from becoming worse than the defect: a bare
|
|
5594
|
+
word must stay unrecognized, or `Durable owner: pending` would be reported
|
|
5595
|
+
as a file that does not exist and send the author to create one.
|
|
5596
|
+
"""
|
|
5597
|
+
label = "a-root-file-is-a-path"
|
|
5598
|
+
|
|
5599
|
+
with tempfile.TemporaryDirectory(prefix="keel-root-file-") as raw:
|
|
5600
|
+
repo = Path(raw) / "repo"
|
|
5601
|
+
repo.mkdir()
|
|
5602
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
5603
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
5604
|
+
write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
|
|
5605
|
+
write_text(
|
|
5606
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
5607
|
+
"## ADDED Requirements\n",
|
|
5608
|
+
)
|
|
5609
|
+
write_text(repo / "AGENTS.md", "# Agents\n")
|
|
5610
|
+
|
|
5611
|
+
def completion(findings: str) -> dict:
|
|
5612
|
+
write_text(
|
|
5613
|
+
tasks_path,
|
|
5614
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
5615
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
5616
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
5617
|
+
.replace(
|
|
5618
|
+
" - Acceptance check: pending\n",
|
|
5619
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
5620
|
+
)
|
|
5621
|
+
.replace(
|
|
5622
|
+
" - Scope check: pending\n",
|
|
5623
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
5624
|
+
)
|
|
5625
|
+
.replace(" - Findings: pending\n", f" - Findings: {findings}\n")
|
|
5626
|
+
)
|
|
5627
|
+
record_contract_anchor(repo, "demo")
|
|
5628
|
+
result = run_keel(
|
|
5629
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
|
|
5630
|
+
"--json",
|
|
5631
|
+
)
|
|
5632
|
+
return json.loads(result.stdout)
|
|
5633
|
+
|
|
5634
|
+
def messages(payload: dict) -> str:
|
|
5635
|
+
return " ".join(
|
|
5636
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
5637
|
+
)
|
|
5638
|
+
|
|
5639
|
+
accepted = (
|
|
5640
|
+
("a root file as a durable owner", "still open. Durable owner: AGENTS.md"),
|
|
5641
|
+
("a root file as resolution evidence", "repaired. Resolved here: AGENTS.md"),
|
|
5642
|
+
("the same file spelled with ./", "still open. Durable owner: ./AGENTS.md"),
|
|
5643
|
+
(
|
|
5644
|
+
"a root file ending a sentence",
|
|
5645
|
+
"still open. Durable owner: AGENTS.md.",
|
|
5646
|
+
),
|
|
5647
|
+
)
|
|
5648
|
+
for description, findings in accepted:
|
|
5649
|
+
payload = completion(findings)
|
|
5650
|
+
if payload.get("status") != "pass":
|
|
5651
|
+
report(
|
|
5652
|
+
f"{label}: {description} was refused. The file exists at the "
|
|
5653
|
+
"repository root; requiring a separator refuses a path the "
|
|
5654
|
+
"author may name and leaves them only a notation to change."
|
|
5655
|
+
)
|
|
5656
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5657
|
+
return 1
|
|
5658
|
+
|
|
5659
|
+
# Existence still decides.
|
|
5660
|
+
payload = completion("still open. Durable owner: NOT-THERE.md")
|
|
5661
|
+
if payload.get("status") == "pass":
|
|
5662
|
+
report(f"{label}: a root file that does not exist was accepted.")
|
|
5663
|
+
return 1
|
|
5664
|
+
|
|
5665
|
+
# And where the gate names a missing path, it names a root file the
|
|
5666
|
+
# same way. `Findings` reports the generic owner refusal for every
|
|
5667
|
+
# unusable owner and always has; `## Invalidates` is the reader that
|
|
5668
|
+
# names the file, so the naming is asserted there rather than claimed
|
|
5669
|
+
# of a reader that never did it.
|
|
5670
|
+
def invalidates(closure: str) -> dict:
|
|
5671
|
+
write_text(
|
|
5672
|
+
tasks_path,
|
|
5673
|
+
task_contract_fixture().replace(
|
|
5674
|
+
"## Invalidates\n\n- None.\n\n",
|
|
5675
|
+
'## Invalidates\n\n- I1: "the wording that is now wrong" '
|
|
5676
|
+
f"— somewhere in the repo. {closure}\n\n",
|
|
5677
|
+
),
|
|
5678
|
+
)
|
|
5679
|
+
result = run_keel(
|
|
5680
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
5681
|
+
"--json",
|
|
5682
|
+
)
|
|
5683
|
+
return json.loads(result.stdout)
|
|
5684
|
+
|
|
5685
|
+
payload = invalidates("Durable owner: AGENTS.md")
|
|
5686
|
+
if payload.get("status") != "pass":
|
|
5687
|
+
report(
|
|
5688
|
+
f"{label}: a root file was refused as an invalidation owner. "
|
|
5689
|
+
"One extractor serves every reader, so a form accepted in one "
|
|
5690
|
+
"must be accepted in all."
|
|
5691
|
+
)
|
|
5692
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
5693
|
+
return 1
|
|
5694
|
+
|
|
5695
|
+
payload = invalidates("Durable owner: NOT-THERE.md")
|
|
5696
|
+
text = messages(payload)
|
|
5697
|
+
if payload.get("status") == "pass":
|
|
5698
|
+
report(f"{label}: a missing root file closed an invalidation entry.")
|
|
5699
|
+
return 1
|
|
5700
|
+
if "NOT-THERE.md" not in text:
|
|
5701
|
+
report(
|
|
5702
|
+
f"{label}: the refusal for a missing root file did not name it. "
|
|
5703
|
+
"A path is the one form a gate can check, and naming what it "
|
|
5704
|
+
f"looked for is what makes the refusal repairable. Got: {text}"
|
|
5705
|
+
)
|
|
5706
|
+
return 1
|
|
5707
|
+
|
|
5708
|
+
# The boundary: a value with no path shape stays unrecognized, so the
|
|
5709
|
+
# author is not sent to create a file named `pending`.
|
|
5710
|
+
for description, findings in (
|
|
5711
|
+
("a bare word", "still open. Durable owner: pending"),
|
|
5712
|
+
("a version string", "still open. Durable owner: 5.44.0"),
|
|
5713
|
+
):
|
|
5714
|
+
payload = completion(findings)
|
|
5715
|
+
text = messages(payload)
|
|
5716
|
+
if payload.get("status") == "pass":
|
|
5717
|
+
report(f"{label}: {description} was accepted as a durable owner.")
|
|
5718
|
+
return 1
|
|
5719
|
+
if "does not exist" in text:
|
|
5720
|
+
report(
|
|
5721
|
+
f"{label}: {description} was reported as a file that does "
|
|
5722
|
+
"not exist. It is not a path, and saying it is missing "
|
|
5723
|
+
"sends the author to create it. Got: " + text
|
|
5724
|
+
)
|
|
5725
|
+
return 1
|
|
5726
|
+
|
|
5727
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
5728
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
5729
|
+
return 1
|
|
5730
|
+
report(f"{label} scenario passed.")
|
|
5731
|
+
return 0
|
|
5732
|
+
|
|
5733
|
+
|
|
5271
5734
|
def validate_an_owner_outlives_the_change_scenario() -> int:
|
|
5272
5735
|
"""Issue #100: the existence check expires at archive.
|
|
5273
5736
|
|
|
@@ -9355,7 +9818,10 @@ def validate_spec_template_validates_scenario() -> int:
|
|
|
9355
9818
|
)
|
|
9356
9819
|
return 1
|
|
9357
9820
|
if run_openspec(ROOT, "--version") is None:
|
|
9358
|
-
report(
|
|
9821
|
+
report(
|
|
9822
|
+
"spec-template-validates skipped: the openspec CLI could not be "
|
|
9823
|
+
"found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
|
|
9824
|
+
)
|
|
9359
9825
|
return 0
|
|
9360
9826
|
|
|
9361
9827
|
filled = fill_template_slots(shipped.read_text(encoding="utf-8"))
|
|
@@ -13309,13 +13775,42 @@ SUPPORTED_VERIFICATION_STRATEGIES = (
|
|
|
13309
13775
|
)
|
|
13310
13776
|
|
|
13311
13777
|
|
|
13312
|
-
|
|
13313
|
-
|
|
13778
|
+
# `@fission-ai/openspec` is this package's own declared dependency, and
|
|
13779
|
+
# `npm install` puts an executable at `node_modules/.bin/openspec`. npm scripts
|
|
13780
|
+
# see that directory on PATH; a Python subprocess started by
|
|
13781
|
+
# `node scripts/run_python.js` does not — so a checkout that had installed
|
|
13782
|
+
# everything it declares reported `validation --all failed for:
|
|
13783
|
+
# compact-task-authoring` while the schema it could supposedly not resolve
|
|
13784
|
+
# resolved perfectly (issue #105).
|
|
13785
|
+
#
|
|
13786
|
+
# The declared dependency wins over PATH. It is the version this repository is
|
|
13787
|
+
# tested against; a global install that happens to be on PATH is a different
|
|
13788
|
+
# version answering for it.
|
|
13789
|
+
OPENSPEC_SEARCH_ORDER = (
|
|
13790
|
+
"the package's own node_modules/.bin",
|
|
13791
|
+
"PATH",
|
|
13792
|
+
)
|
|
13793
|
+
|
|
13794
|
+
|
|
13795
|
+
def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
|
|
13796
|
+
local = ROOT / "node_modules" / ".bin" / "openspec"
|
|
13797
|
+
if local.is_file():
|
|
13798
|
+
return str(local)
|
|
13799
|
+
return shutil.which("openspec", path=(env or os.environ).get("PATH"))
|
|
13800
|
+
|
|
13801
|
+
|
|
13802
|
+
def run_openspec(
|
|
13803
|
+
cwd: Path,
|
|
13804
|
+
*args: str,
|
|
13805
|
+
env: dict[str, str] | None = None,
|
|
13806
|
+
) -> subprocess.CompletedProcess[str] | None:
|
|
13807
|
+
executable = resolve_openspec(env)
|
|
13314
13808
|
if executable is None:
|
|
13315
13809
|
return None
|
|
13316
13810
|
return subprocess.run(
|
|
13317
13811
|
[executable, *args],
|
|
13318
13812
|
cwd=cwd,
|
|
13813
|
+
env=env,
|
|
13319
13814
|
text=True,
|
|
13320
13815
|
encoding="utf-8",
|
|
13321
13816
|
errors="replace",
|
|
@@ -14812,10 +15307,22 @@ def validate_compact_task_authoring_scenario() -> int:
|
|
|
14812
15307
|
local_root = ROOT / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
14813
15308
|
|
|
14814
15309
|
which = run_openspec(ROOT, "schema", "which", OPENSPEC_SCHEMA_NAME, "--json")
|
|
14815
|
-
|
|
14816
|
-
|
|
14817
|
-
|
|
14818
|
-
|
|
15310
|
+
# Two distinct facts, kept apart. A CLI that was never found says nothing
|
|
15311
|
+
# about the schema, and reporting the schema as unresolvable sends the
|
|
15312
|
+
# reader to a subject with nothing wrong with it (issue #105).
|
|
15313
|
+
if which is None:
|
|
15314
|
+
report(
|
|
15315
|
+
"compact-task-authoring skipped: the openspec CLI could not be "
|
|
15316
|
+
"found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER)
|
|
15317
|
+
+ ". Run `npm install` to provide the declared dependency."
|
|
15318
|
+
)
|
|
15319
|
+
return 3
|
|
15320
|
+
if which.returncode != 0:
|
|
15321
|
+
report(
|
|
15322
|
+
"compact-task-authoring could not resolve the schema through "
|
|
15323
|
+
"OpenSpec. The CLI ran and refused:"
|
|
15324
|
+
)
|
|
15325
|
+
report((which.stderr or which.stdout).strip())
|
|
14819
15326
|
return 1
|
|
14820
15327
|
which_payload = json.loads(which.stdout[which.stdout.index("{"):])
|
|
14821
15328
|
resolved = Path(which_payload.get("path", ""))
|
|
@@ -24362,6 +24869,19 @@ SCENARIOS: tuple = (
|
|
|
24362
24869
|
"an-owner-outlives-the-change",
|
|
24363
24870
|
validate_an_owner_outlives_the_change_scenario,
|
|
24364
24871
|
),
|
|
24872
|
+
("a-root-file-is-a-path", validate_a_root_file_is_a_path_scenario),
|
|
24873
|
+
(
|
|
24874
|
+
"a-declared-dependency-is-resolved",
|
|
24875
|
+
validate_a_declared_dependency_is_resolved_scenario,
|
|
24876
|
+
),
|
|
24877
|
+
(
|
|
24878
|
+
"the-tarball-is-the-repository",
|
|
24879
|
+
validate_the_tarball_is_the_repository_scenario,
|
|
24880
|
+
),
|
|
24881
|
+
(
|
|
24882
|
+
"an-invalidates-phrase-may-wrap",
|
|
24883
|
+
validate_an_invalidates_phrase_may_wrap_scenario,
|
|
24884
|
+
),
|
|
24365
24885
|
(
|
|
24366
24886
|
"the-spec-names-the-managed-set",
|
|
24367
24887
|
validate_the_spec_names_the_managed_set_scenario,
|
package/src/core/gates.js
CHANGED
|
@@ -417,9 +417,23 @@ const DURABLE_OWNER_FORMS =
|
|
|
417
417
|
// swallows it and the gate looks for a file that cannot exist.
|
|
418
418
|
const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
|
|
419
419
|
|
|
420
|
-
// A
|
|
421
|
-
//
|
|
422
|
-
//
|
|
420
|
+
// A file at the repository root carries no separator, and nine of them sit at
|
|
421
|
+
// this repository's root — `AGENTS.md`, `README.md`, `package.json` among them
|
|
422
|
+
// — every one a legitimate owner. Requiring a separator refused them with "it
|
|
423
|
+
// names neither a check nor a path", for a path whose file exists, and left
|
|
424
|
+
// the author only `./AGENTS.md`: a concession to this function rather than a
|
|
425
|
+
// path anyone meant (issue #107).
|
|
426
|
+
//
|
|
427
|
+
// The shape is a trailing extension beginning with a letter. That is what
|
|
428
|
+
// keeps a bare word unrecognized — `Durable owner: pending` reported as a
|
|
429
|
+
// missing file would send the author to create one — and what keeps a version
|
|
430
|
+
// string out, since `5.44.0` would otherwise read as `44` with extension `0`,
|
|
431
|
+
// and authors write versions in prose beside an owner.
|
|
432
|
+
const ROOT_FILE_NAME = /^[^\s`]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
433
|
+
|
|
434
|
+
// A declared path is a run of non-whitespace. What ends a path is whitespace;
|
|
435
|
+
// what a path is *made of* is the filesystem's business, and answering the
|
|
436
|
+
// first question with the second is what refused
|
|
423
437
|
// `notes/note-006-转岗最难的不是流程/note.md` by reporting that
|
|
424
438
|
// `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
|
|
425
439
|
// same class as #40 on the worktree-reading side, which survived because that
|
|
@@ -435,8 +449,15 @@ function declaredPath(value) {
|
|
|
435
449
|
const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
|
|
436
450
|
if (quoted) return quoted[1].trim() || null;
|
|
437
451
|
const bare = text.match(/[^\s`]+\/[^\s`]+/);
|
|
438
|
-
if (
|
|
439
|
-
|
|
452
|
+
if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
453
|
+
// The separator form is tried first and is unchanged, so nothing that
|
|
454
|
+
// resolves today resolves differently. The trim runs before the shape is
|
|
455
|
+
// judged, so a root file ending a sentence is still a root file.
|
|
456
|
+
for (const token of text.split(/\s+/)) {
|
|
457
|
+
const trimmed = token.replace(DECLARED_PATH_TRAILING, "");
|
|
458
|
+
if (trimmed && ROOT_FILE_NAME.test(trimmed)) return trimmed;
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
440
461
|
}
|
|
441
462
|
|
|
442
463
|
// A path inside the selected change's own directory exists now and cannot
|
|
@@ -1225,7 +1246,15 @@ function invalidationProblems(repo, content, tasks, change) {
|
|
|
1225
1246
|
const problems = [];
|
|
1226
1247
|
for (const entry of entries) {
|
|
1227
1248
|
const [, id, body] = entry;
|
|
1228
|
-
|
|
1249
|
+
// The quotation is read across the entry, not one line of it. An entry
|
|
1250
|
+
// carries a quotation, a location, and a closure, and wraps as often as it
|
|
1251
|
+
// needs to — 42 of this repository's 194 archived entries span more than
|
|
1252
|
+
// one line. Requiring the quotation to fit on one refused entries that had
|
|
1253
|
+
// named exactly what was asked for, and offered no repair but reflowing
|
|
1254
|
+
// the text (issue #108). The bound is the entry: `entries` above splits on
|
|
1255
|
+
// the next `I<n>`, so a quotation cannot reach past its own. `Findings` in
|
|
1256
|
+
// this same file is already read as wrapping.
|
|
1257
|
+
if (!/"[^"]{3,}"/.test(body)) {
|
|
1229
1258
|
problems.push(
|
|
1230
1259
|
problem(
|
|
1231
1260
|
"invalidation-phrase",
|