@christang/keel 5.50.0 → 5.52.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.52.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.52.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.52.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.52.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
|
|
@@ -25222,6 +25222,300 @@ def validate_weakest_strategy_states_its_reason_scenario() -> int:
|
|
|
25222
25222
|
return 0
|
|
25223
25223
|
|
|
25224
25224
|
|
|
25225
|
+
# A completed task fixture whose Review Findings the caller writes. Everything
|
|
25226
|
+
# else is the shape `task-complete` needs so that the only thing under test is
|
|
25227
|
+
# the Findings block.
|
|
25228
|
+
def findings_fixture_repo(root: Path, name: str, findings: str) -> Path:
|
|
25229
|
+
repo = root / name
|
|
25230
|
+
task = strategy_probe_task(strategy="vertical-tdd")
|
|
25231
|
+
task = task.replace("- [ ] 1.1", "- [x] 1.1")
|
|
25232
|
+
task = task.replace(
|
|
25233
|
+
" - M1: pending",
|
|
25234
|
+
" - M1: pass. node test.js reported 3 passing.\n"
|
|
25235
|
+
" - M1.red: fail, for the right reason. the assertion was absent.\n"
|
|
25236
|
+
" - M1.green: pass. node test.js reported 3 passing.",
|
|
25237
|
+
)
|
|
25238
|
+
task = task.replace(" - Findings: none", f" - Findings: {findings}")
|
|
25239
|
+
write_gate_fixture(repo, tasks=task)
|
|
25240
|
+
started = run_keel(
|
|
25241
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
25242
|
+
)
|
|
25243
|
+
fingerprint = json.loads(started.stdout)["contract"]["fingerprint"]["value"]
|
|
25244
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
25245
|
+
tasks_path.write_text(
|
|
25246
|
+
tasks_path.read_text(encoding="utf-8").replace(
|
|
25247
|
+
" - Contract: pending",
|
|
25248
|
+
f" - Contract: keel-task-capsule/v1 sha256:{fingerprint}",
|
|
25249
|
+
),
|
|
25250
|
+
encoding="utf-8",
|
|
25251
|
+
)
|
|
25252
|
+
return repo
|
|
25253
|
+
|
|
25254
|
+
|
|
25255
|
+
def findings_completion(root: Path, name: str, findings: str) -> dict:
|
|
25256
|
+
repo = findings_fixture_repo(root, name, findings)
|
|
25257
|
+
result = run_keel(
|
|
25258
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
25259
|
+
)
|
|
25260
|
+
try:
|
|
25261
|
+
return json.loads(result.stdout)
|
|
25262
|
+
except json.JSONDecodeError:
|
|
25263
|
+
return {"status": "unparsed", "problems": [{"message": result.stdout[:400]}]}
|
|
25264
|
+
|
|
25265
|
+
|
|
25266
|
+
# `Findings` is free prose by design, and in a repository whose subject is the
|
|
25267
|
+
# protocol that prose names the markers themselves. A quoted marker was read as
|
|
25268
|
+
# a disposition — and because the resolution scan is global and stops at the
|
|
25269
|
+
# first claim it cannot resolve, one mention eclipsed every real disposition
|
|
25270
|
+
# beside it. 5.42.0 already decided that an inline-code span holds quoted
|
|
25271
|
+
# material rather than an assertion; the rule had not reached this scan.
|
|
25272
|
+
def validate_quoted_marker_is_not_a_disposition_scenario() -> int:
|
|
25273
|
+
label = "a-quoted-marker-is-not-a-disposition"
|
|
25274
|
+
tracker = "https://github.com/TanglmChris/keel/issues/114"
|
|
25275
|
+
with tempfile.TemporaryDirectory(prefix="keel-quoted-marker-") as raw:
|
|
25276
|
+
root = Path(raw)
|
|
25277
|
+
|
|
25278
|
+
owned = findings_completion(
|
|
25279
|
+
root,
|
|
25280
|
+
"owned",
|
|
25281
|
+
"one, still open. The `Resolved here:` grammar is what this finding "
|
|
25282
|
+
f"is about. Durable owner: {tracker}",
|
|
25283
|
+
)
|
|
25284
|
+
if owned.get("status") != "pass":
|
|
25285
|
+
report(
|
|
25286
|
+
f"{label}: a quoted marker eclipsed a genuine tracker owner; "
|
|
25287
|
+
f"{problem_codes(owned)!r} {problem_text(owned)!r}."
|
|
25288
|
+
)
|
|
25289
|
+
return 1
|
|
25290
|
+
|
|
25291
|
+
resolved = findings_completion(
|
|
25292
|
+
root,
|
|
25293
|
+
"resolved",
|
|
25294
|
+
"one, fixed here. The `Durable owner:` form is what this finding is "
|
|
25295
|
+
"about. Resolved here: M1",
|
|
25296
|
+
)
|
|
25297
|
+
if resolved.get("status") != "pass":
|
|
25298
|
+
report(
|
|
25299
|
+
f"{label}: a quoted marker eclipsed a genuine resolution; "
|
|
25300
|
+
f"{problem_codes(resolved)!r} {problem_text(resolved)!r}."
|
|
25301
|
+
)
|
|
25302
|
+
return 1
|
|
25303
|
+
|
|
25304
|
+
# Every marker, not only the one that fired.
|
|
25305
|
+
for index, marker in enumerate(
|
|
25306
|
+
("Resolved here:", "Durable owner:", "Discard reason:", "Discard rationale:")
|
|
25307
|
+
):
|
|
25308
|
+
only = findings_completion(
|
|
25309
|
+
root,
|
|
25310
|
+
f"only-{index}",
|
|
25311
|
+
f"one. The `{marker}` marker is the topic of this finding, and "
|
|
25312
|
+
"nothing here disposes of it.",
|
|
25313
|
+
)
|
|
25314
|
+
codes = problem_codes(only)
|
|
25315
|
+
if only.get("status") != "fail":
|
|
25316
|
+
report(
|
|
25317
|
+
f"{label}: a block whose only {marker!r} is quoted was "
|
|
25318
|
+
"accepted, so the quotation created a disposition."
|
|
25319
|
+
)
|
|
25320
|
+
return 1
|
|
25321
|
+
if "finding-resolution-evidence" in codes:
|
|
25322
|
+
report(
|
|
25323
|
+
f"{label}: a block whose only {marker!r} is quoted was "
|
|
25324
|
+
"refused as a resolution with unusable evidence; it carries "
|
|
25325
|
+
f"no disposition at all. Codes {codes!r}."
|
|
25326
|
+
)
|
|
25327
|
+
return 1
|
|
25328
|
+
if "finding-owner" not in codes:
|
|
25329
|
+
report(
|
|
25330
|
+
f"{label}: a block whose only {marker!r} is quoted did not "
|
|
25331
|
+
f"report the missing-disposition problem. Codes {codes!r}."
|
|
25332
|
+
)
|
|
25333
|
+
return 1
|
|
25334
|
+
|
|
25335
|
+
# D5: the refusal names what it read.
|
|
25336
|
+
misread = findings_completion(
|
|
25337
|
+
root, "misread", "one. Resolved here: thoroughly, by rewriting it."
|
|
25338
|
+
)
|
|
25339
|
+
if misread.get("status") != "fail":
|
|
25340
|
+
report(f"{label}: a resolution naming no evidence was accepted.")
|
|
25341
|
+
return 1
|
|
25342
|
+
if "thoroughly" not in problem_text(misread):
|
|
25343
|
+
report(
|
|
25344
|
+
f"{label}: the refusal does not name the text it read as "
|
|
25345
|
+
f"evidence; got {problem_text(misread)!r}."
|
|
25346
|
+
)
|
|
25347
|
+
return 1
|
|
25348
|
+
|
|
25349
|
+
# The verdicts that must not move.
|
|
25350
|
+
plain = findings_completion(root, "plain", "one, fixed. Resolved here: M1")
|
|
25351
|
+
if plain.get("status") != "pass":
|
|
25352
|
+
report(
|
|
25353
|
+
f"{label}: an ordinary resolution was refused; "
|
|
25354
|
+
f"{problem_text(plain)!r}."
|
|
25355
|
+
)
|
|
25356
|
+
return 1
|
|
25357
|
+
undeclared = findings_completion(
|
|
25358
|
+
root, "undeclared", "one, fixed. Resolved here: M9"
|
|
25359
|
+
)
|
|
25360
|
+
if undeclared.get("status") != "fail":
|
|
25361
|
+
report(
|
|
25362
|
+
f"{label}: a resolution naming a check the task does not "
|
|
25363
|
+
"declare was accepted."
|
|
25364
|
+
)
|
|
25365
|
+
return 1
|
|
25366
|
+
missing_path = findings_completion(
|
|
25367
|
+
root, "missing-path", "one, open. Durable owner: docs/nowhere.md"
|
|
25368
|
+
)
|
|
25369
|
+
if missing_path.get("status") != "fail":
|
|
25370
|
+
report(
|
|
25371
|
+
f"{label}: an owner naming a path that does not exist was "
|
|
25372
|
+
"accepted."
|
|
25373
|
+
)
|
|
25374
|
+
return 1
|
|
25375
|
+
|
|
25376
|
+
# A disposition may legitimately wrap its value in backticks. The first
|
|
25377
|
+
# draft of the blanking removed whole code spans and destroyed exactly
|
|
25378
|
+
# these two forms; nothing in the suite noticed, which is why they are
|
|
25379
|
+
# asserted here rather than left to the reader who happens to ask.
|
|
25380
|
+
for name, findings in (
|
|
25381
|
+
("backticked-owner", "one, open. Durable owner: `keel/archive/note.md`"),
|
|
25382
|
+
("backticked-resolution", "one, fixed. Resolved here: `src/example.js`"),
|
|
25383
|
+
):
|
|
25384
|
+
repo = findings_fixture_repo(root, name, findings)
|
|
25385
|
+
write_text(repo / "keel/archive/note.md", "note\n")
|
|
25386
|
+
write_text(repo / "src/example.js", "//\n")
|
|
25387
|
+
result = run_keel(
|
|
25388
|
+
repo, "gate", "task-complete", "--change", "demo", "--task",
|
|
25389
|
+
"1.1", "--json",
|
|
25390
|
+
)
|
|
25391
|
+
payload = json.loads(result.stdout)
|
|
25392
|
+
if payload.get("status") != "pass":
|
|
25393
|
+
report(
|
|
25394
|
+
f"{label}: a disposition whose value is wrapped in "
|
|
25395
|
+
f"backticks was refused ({name}); "
|
|
25396
|
+
f"{problem_codes(payload)!r} {problem_text(payload)!r}."
|
|
25397
|
+
)
|
|
25398
|
+
return 1
|
|
25399
|
+
|
|
25400
|
+
report(f"{label} scenario passed.")
|
|
25401
|
+
return 0
|
|
25402
|
+
|
|
25403
|
+
|
|
25404
|
+
# A drift is a hard stop, so whatever the message says is what the author acts
|
|
25405
|
+
# on. Saying only that a value moved produced a wrong record in this repository:
|
|
25406
|
+
# the cause was recorded as a Review edit, which is measurably not covered by
|
|
25407
|
+
# the fingerprint at all.
|
|
25408
|
+
def validate_drift_names_where_to_look_scenario() -> int:
|
|
25409
|
+
label = "drift-names-where-to-look"
|
|
25410
|
+
|
|
25411
|
+
def fixture(root: Path, name: str, anchor_value: str) -> Path:
|
|
25412
|
+
repo = root / name
|
|
25413
|
+
task = strategy_probe_task(strategy="vertical-tdd")
|
|
25414
|
+
# Cite a design statement so the capsule holds two distinct sources.
|
|
25415
|
+
task = task.replace(
|
|
25416
|
+
" - E1: the task proves its own behavior",
|
|
25417
|
+
" - E1: the task proves its own behavior\n - D1",
|
|
25418
|
+
)
|
|
25419
|
+
task = task.replace(
|
|
25420
|
+
" - Contract: pending",
|
|
25421
|
+
f" - Contract: keel-task-capsule/v1 sha256:{anchor_value}",
|
|
25422
|
+
)
|
|
25423
|
+
write_gate_fixture(
|
|
25424
|
+
repo,
|
|
25425
|
+
tasks=task,
|
|
25426
|
+
design=(
|
|
25427
|
+
"## Context\n\nfixture\n\n## Decisions\n\n"
|
|
25428
|
+
"- D1 — the public behavior is proven through the CLI. "
|
|
25429
|
+
"Basis: fixture.\n"
|
|
25430
|
+
),
|
|
25431
|
+
)
|
|
25432
|
+
return repo
|
|
25433
|
+
|
|
25434
|
+
with tempfile.TemporaryDirectory(prefix="keel-drift-") as raw:
|
|
25435
|
+
root = Path(raw)
|
|
25436
|
+
|
|
25437
|
+
# The true anchor, so the matching case can be compared against it.
|
|
25438
|
+
settled = fixture(root, "settled", "0" * 64)
|
|
25439
|
+
started = run_keel(
|
|
25440
|
+
settled, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
25441
|
+
"--json", "--no-guard",
|
|
25442
|
+
)
|
|
25443
|
+
true_value = json.loads(started.stdout)["contract"]["fingerprint"]["value"]
|
|
25444
|
+
|
|
25445
|
+
drifted = fixture(root, "drifted", "0" * 64)
|
|
25446
|
+
result = run_keel(drifted, "context", "--change", "demo", "--json")
|
|
25447
|
+
payload = json.loads(result.stdout)
|
|
25448
|
+
if payload.get("status") != "blocked":
|
|
25449
|
+
report(
|
|
25450
|
+
f"{label}: a task whose anchor does not match was not blocked; "
|
|
25451
|
+
f"status {payload.get('status')!r}."
|
|
25452
|
+
)
|
|
25453
|
+
return 1
|
|
25454
|
+
reason = " ".join(str(x) for x in (payload.get("reasons") or []))
|
|
25455
|
+
|
|
25456
|
+
for needed in ("0" * 8, true_value[:8]):
|
|
25457
|
+
if needed not in reason:
|
|
25458
|
+
report(
|
|
25459
|
+
f"{label}: the drift reason no longer reports both "
|
|
25460
|
+
f"fingerprints; {reason!r}."
|
|
25461
|
+
)
|
|
25462
|
+
return 1
|
|
25463
|
+
for source in (
|
|
25464
|
+
"openspec/changes/demo/tasks.md",
|
|
25465
|
+
"openspec/changes/demo/design.md",
|
|
25466
|
+
):
|
|
25467
|
+
if source not in reason:
|
|
25468
|
+
report(
|
|
25469
|
+
f"{label}: the drift reason does not name the authority "
|
|
25470
|
+
f"source {source!r}; {reason!r}."
|
|
25471
|
+
)
|
|
25472
|
+
return 1
|
|
25473
|
+
# The list is the authority set, not the change directory.
|
|
25474
|
+
if "proposal.md" in reason:
|
|
25475
|
+
report(
|
|
25476
|
+
f"{label}: the drift reason names a file the capsule resolved "
|
|
25477
|
+
f"no authority text from; {reason!r}."
|
|
25478
|
+
)
|
|
25479
|
+
return 1
|
|
25480
|
+
lowered = reason.lower()
|
|
25481
|
+
for word in ("evidence", "review", "checkbox"):
|
|
25482
|
+
if word not in lowered:
|
|
25483
|
+
report(
|
|
25484
|
+
f"{label}: the drift reason does not say that {word} is "
|
|
25485
|
+
f"outside the fingerprint; {reason!r}."
|
|
25486
|
+
)
|
|
25487
|
+
return 1
|
|
25488
|
+
# D3: it names a search set, never a culprit.
|
|
25489
|
+
for guess in ("changed field", "the field that changed", "caused by"):
|
|
25490
|
+
if guess in lowered:
|
|
25491
|
+
report(
|
|
25492
|
+
f"{label}: the drift reason claims which field moved, which "
|
|
25493
|
+
f"it cannot know; {reason!r}."
|
|
25494
|
+
)
|
|
25495
|
+
return 1
|
|
25496
|
+
|
|
25497
|
+
matching = fixture(root, "matching", true_value)
|
|
25498
|
+
ok = json.loads(
|
|
25499
|
+
run_keel(matching, "context", "--change", "demo", "--json").stdout
|
|
25500
|
+
)
|
|
25501
|
+
if ok.get("status") != "ready":
|
|
25502
|
+
report(
|
|
25503
|
+
f"{label}: a task whose anchor matches was not ready; "
|
|
25504
|
+
f"{ok.get('status')!r} {ok.get('reasons')!r}."
|
|
25505
|
+
)
|
|
25506
|
+
return 1
|
|
25507
|
+
ok_reason = " ".join(str(x) for x in (ok.get("reasons") or []))
|
|
25508
|
+
if "fingerprint" in ok_reason.lower():
|
|
25509
|
+
report(
|
|
25510
|
+
f"{label}: a matching anchor now reports drift wording; "
|
|
25511
|
+
f"{ok_reason!r}."
|
|
25512
|
+
)
|
|
25513
|
+
return 1
|
|
25514
|
+
|
|
25515
|
+
report(f"{label} scenario passed.")
|
|
25516
|
+
return 0
|
|
25517
|
+
|
|
25518
|
+
|
|
25225
25519
|
# A scenario name, as the registry spells one. Two registered names carry no
|
|
25226
25520
|
# hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
|
|
25227
25521
|
# two unchecked, and allowing single words was measured to add no false
|
|
@@ -25455,6 +25749,8 @@ SCENARIOS: tuple = (
|
|
|
25455
25749
|
("output-survives-the-pipe", validate_output_survives_the_pipe_scenario),
|
|
25456
25750
|
("a-strategy-is-declared", validate_strategy_is_declared_scenario),
|
|
25457
25751
|
("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
|
|
25752
|
+
("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
|
|
25753
|
+
("drift-names-where-to-look", validate_drift_names_where_to_look_scenario),
|
|
25458
25754
|
(
|
|
25459
25755
|
"authored-scenario-names-are-registered",
|
|
25460
25756
|
validate_authored_scenario_names_scenario,
|
package/src/core/context.js
CHANGED
|
@@ -85,6 +85,32 @@ function taskHasCompletionEvidence(record, contract) {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Two hashes and nothing to search was the whole message, and it produced a
|
|
89
|
+
// wrong record here: an anchor that moved was written up as a Review edit,
|
|
90
|
+
// which is measurably not covered at all. The capsule already knows the answer
|
|
91
|
+
// — every authority entry carries the source its text was resolved from — so
|
|
92
|
+
// the search set is free. What is not free is the field that moved: only the
|
|
93
|
+
// previous fingerprint is retained, not the capsule behind it, so this names
|
|
94
|
+
// where to look and never what changed.
|
|
95
|
+
function driftSearchSet(contract) {
|
|
96
|
+
const sources = [
|
|
97
|
+
...new Set(
|
|
98
|
+
(contract.capsule.authority || [])
|
|
99
|
+
.map((entry) => String(entry.source || "").split("#")[0])
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
),
|
|
102
|
+
];
|
|
103
|
+
const where = sources.length > 0
|
|
104
|
+
? `The fingerprint covers text resolved from: ${sources.join(", ")}.`
|
|
105
|
+
: "The fingerprint covers this task's own resolved authority text.";
|
|
106
|
+
return (
|
|
107
|
+
`${where} Evidence, Review, and the task checkbox are not covered — `
|
|
108
|
+
+ "editing them does not move it. Reauthorize by re-running "
|
|
109
|
+
+ "`keel gate task-start` and recording the new anchor, after confirming "
|
|
110
|
+
+ "the change to the authority above was intended."
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
function taskSelection(repo, change, record, source, requestedAction = null) {
|
|
89
115
|
const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
|
|
90
116
|
const contract = compileTaskContract(repo, change, record);
|
|
@@ -100,7 +126,8 @@ function taskSelection(repo, change, record, source, requestedAction = null) {
|
|
|
100
126
|
if (anchor && anchor !== contract.fingerprint.value) {
|
|
101
127
|
return blocked(
|
|
102
128
|
`Task contract fingerprint drift for ${change}#${record.id}: recorded `
|
|
103
|
-
+ `sha256:${anchor}, current sha256:${contract.fingerprint.value}
|
|
129
|
+
+ `sha256:${anchor}, current sha256:${contract.fingerprint.value}. `
|
|
130
|
+
+ `${driftSearchSet(contract)}`,
|
|
104
131
|
[relativePath(repo, tasksPath)]
|
|
105
132
|
);
|
|
106
133
|
}
|
package/src/core/gates.js
CHANGED
|
@@ -582,7 +582,7 @@ function resolutionEvidenceVerdict(repo, value, commands, change) {
|
|
|
582
582
|
return { ok: false, reason: "unknown-check", label: cited[0] };
|
|
583
583
|
}
|
|
584
584
|
const candidate = declaredPath(evidence);
|
|
585
|
-
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
585
|
+
if (!candidate) return { ok: false, reason: "unrecognized", token: evidence };
|
|
586
586
|
// Resolution evidence is a file like any other and moves with the directory
|
|
587
587
|
// holding it, so it earns the same verdict rather than a second answer to
|
|
588
588
|
// the same question.
|
|
@@ -593,6 +593,30 @@ function resolutionEvidenceVerdict(repo, value, commands, change) {
|
|
|
593
593
|
return { ok: false, reason: "missing", path: candidate };
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
// `Findings` is free prose, and in a repository whose subject is the protocol
|
|
597
|
+
// that prose names the markers themselves. A quoted marker is a quotation, not
|
|
598
|
+
// a disposition — the rule 5.42.0 established for the tasks.md checker and the
|
|
599
|
+
// unfilled-slot scan, arriving here. Each span becomes an equal run of spaces
|
|
600
|
+
// rather than being removed, because every rule downstream reads positionally:
|
|
601
|
+
// `Durable owner:` captures to end of line and `Resolved here:` captures the
|
|
602
|
+
// next token, so a shortened text would move what they read. The original is
|
|
603
|
+
// kept for anything reported back, since a diagnostic quoting the blanked copy
|
|
604
|
+
// would show the author a sentence with holes in it.
|
|
605
|
+
const DISPOSITION_MARKER =
|
|
606
|
+
/\b(?:resolved here|durable owner|discard (?:reason|rationale))\s*:/gi;
|
|
607
|
+
|
|
608
|
+
function withoutQuotedMarkers(text) {
|
|
609
|
+
return String(text || "").replace(/`[^`\n]*`/g, (span) =>
|
|
610
|
+
// Only the marker vocabulary is removed, not the span. A disposition may
|
|
611
|
+
// legitimately wrap its value in backticks — `Durable owner:
|
|
612
|
+
// \`keel/archive/note.md\`` is a supported form — so blanking the whole
|
|
613
|
+
// span would destroy the very path the rule exists to read. Measured: the
|
|
614
|
+
// first draft of this function did exactly that, and both the backticked
|
|
615
|
+
// owner path and the backticked resolution path started failing.
|
|
616
|
+
span.replace(DISPOSITION_MARKER, (marker) => " ".repeat(marker.length))
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
596
620
|
function resolutionEvidenceMessage(verdict) {
|
|
597
621
|
const lead = "Review Findings records a finding as resolved here, but its "
|
|
598
622
|
+ "evidence is not usable — ";
|
|
@@ -615,7 +639,13 @@ function resolutionEvidenceMessage(verdict) {
|
|
|
615
639
|
if (verdict.reason === "missing") {
|
|
616
640
|
return `${lead}\`${verdict.path}\` does not exist.${tail}`;
|
|
617
641
|
}
|
|
618
|
-
|
|
642
|
+
// Naming the token turns an argument into an observation: the author
|
|
643
|
+
// believes they named a check or a path, and this is what the gate read
|
|
644
|
+
// instead.
|
|
645
|
+
return verdict.token
|
|
646
|
+
? `${lead}it read \`${verdict.token}\`, which names neither a check nor a `
|
|
647
|
+
+ `path.${tail}`
|
|
648
|
+
: `${lead}it names neither a check nor a path.${tail}`;
|
|
619
649
|
}
|
|
620
650
|
|
|
621
651
|
function findingOwnerIsDurable(repo, findings, change) {
|
|
@@ -1011,7 +1041,8 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1011
1041
|
// pass as a tracker owner, which is the one reading this disposition must
|
|
1012
1042
|
// not have: a link to work someone else will do is not evidence that this
|
|
1013
1043
|
// task did it.
|
|
1014
|
-
const
|
|
1044
|
+
const scannable = withoutQuotedMarkers(reviewFields.Findings);
|
|
1045
|
+
const resolved = [...scannable.matchAll(RESOLVED_HERE)];
|
|
1015
1046
|
if (resolved.length > 0) {
|
|
1016
1047
|
for (const claim of resolved) {
|
|
1017
1048
|
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
|
|
@@ -1021,7 +1052,7 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
|
|
|
1021
1052
|
);
|
|
1022
1053
|
break;
|
|
1023
1054
|
}
|
|
1024
|
-
} else if (!findingOwnerIsDurable(repo,
|
|
1055
|
+
} else if (!findingOwnerIsDurable(repo, scannable, change)) {
|
|
1025
1056
|
problems.push(
|
|
1026
1057
|
problem(
|
|
1027
1058
|
"finding-owner",
|