@codyswann/lisa 4.45.3 → 4.45.5
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/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +523 -70
- package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +160 -2
- package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +115 -23
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +18 -10
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +40 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa/hooks/block-blind-automerge.agy.sh +5 -4
- package/plugins/lisa/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa/hooks/worktree-binding-guard.mjs +115 -23
- package/plugins/lisa/rules/eager/ready-role-filing.md +3 -2
- package/plugins/lisa/rules/reference/ready-role-filing.md +20 -2
- package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa-agy/hooks/block-blind-automerge.agy.sh +5 -4
- package/plugins/lisa-agy/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +115 -23
- package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +3 -2
- package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +20 -2
- package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +115 -23
- package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +20 -2
- package/plugins/lisa-cursor/rules/ready-role-filing.mdc +3 -2
- package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-blind-automerge.agy.sh +5 -4
- package/plugins/src/base/hooks/block-blind-automerge.sh +523 -70
- package/plugins/src/base/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/src/base/hooks/worktree-binding-guard.mjs +115 -23
- package/plugins/src/base/rules/eager/ready-role-filing.md +3 -2
- package/plugins/src/base/rules/reference/ready-role-filing.md +20 -2
- package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/scripts/check-cwd-resolution-corpus.mjs +163 -0
|
@@ -346,7 +346,7 @@ session found 13 of 13 issues filed this way, none of them through Lisa's
|
|
|
346
346
|
filing path. The rule saying not to do this already existed — it did not bind,
|
|
347
347
|
so it is now enforced here.
|
|
348
348
|
|
|
349
|
-
FILE IT THE SANCTIONED WAY — one of these
|
|
349
|
+
FILE IT THE SANCTIONED WAY — one of these three, always explicit:
|
|
350
350
|
|
|
351
351
|
1. The item is complete enough to build. Use the filing flow, not the CLI:
|
|
352
352
|
|
|
@@ -368,11 +368,26 @@ FILE IT THE SANCTIONED WAY — one of these two, always explicit:
|
|
|
368
368
|
Held for a human product call: <reason>.
|
|
369
369
|
<!-- [lisa-human-gate] reason=<short-slug> -->
|
|
370
370
|
|
|
371
|
+
3. The item is a CONTAINER — an Epic, or any item whose state rolls up from
|
|
372
|
+
its children. It is neither of the two above and must not pretend to be:
|
|
373
|
+
\`leaf-only-lifecycle\` FORBIDS the build-ready role on a container, and a
|
|
374
|
+
human gate it does not have is a durable false hold. It declares the third
|
|
375
|
+
thing instead — the canonical container line, in place of a Target Backend
|
|
376
|
+
Environment:
|
|
377
|
+
|
|
378
|
+
## Target Backend Environment
|
|
379
|
+
|
|
380
|
+
None — container: state rolls up from children
|
|
381
|
+
|
|
382
|
+
That declaration is refused alongside a by-design leaf type
|
|
383
|
+
(\`type:Bug\` / \`type:Task\` / \`type:Sub-task\` / \`type:Improvement\`):
|
|
384
|
+
an item cannot be a container and a leaf at once.
|
|
385
|
+
|
|
371
386
|
Filed, not ready, and no \`human_gate\` is the incomplete-handoff case, and
|
|
372
387
|
\`build_ready: false\` with no reason is the same omission with a value
|
|
373
388
|
attached. See the \`ready-role-filing\` rule for the full contract.
|
|
374
389
|
|
|
375
|
-
If you must run the CLI directly, the command has to carry one of the
|
|
390
|
+
If you must run the CLI directly, the command has to carry one of the three
|
|
376
391
|
declarations itself:
|
|
377
392
|
|
|
378
393
|
$(declaration_hint)
|
|
@@ -380,6 +395,9 @@ $(declaration_hint)
|
|
|
380
395
|
Or a \`[lisa-human-gate]\` marker in the body it submits, when a human
|
|
381
396
|
product call really is pending.
|
|
382
397
|
|
|
398
|
+
Or, for a container, \`None — container: state rolls up from children\` in
|
|
399
|
+
that body, and no by-design leaf type on the command.
|
|
400
|
+
|
|
383
401
|
WHERE THE DECLARATION IS READ FROM: argv, the request payload — inline, in a
|
|
384
402
|
\`--data-binary @file\`, or piped in over stdin — and the contents of a script
|
|
385
403
|
this command runs. Moving the create into a file no longer moves it out of
|
|
@@ -528,6 +546,78 @@ UNPARSEABLE_CREATION = re.compile(
|
|
|
528
546
|
|
|
529
547
|
BODY_FILE_FLAGS = {"--body-file", "-F", "--input", "--data-binary"}
|
|
530
548
|
LABEL_FLAGS = {"--label", "--labels", "--add-label", "--status", "--state"}
|
|
549
|
+
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
# THE THIRD DECLARATION: THIS ITEM IS A CONTAINER
|
|
552
|
+
#
|
|
553
|
+
# The two declarations above are both illegitimate for a container, and the
|
|
554
|
+
# rules say so in as many words. `leaf-only-lifecycle` FORBIDS the build-ready
|
|
555
|
+
# role on an Epic — its state rolls up from its children, and a hand-applied
|
|
556
|
+
# role on a parent is the exact input that rule's claim-time arm exists to
|
|
557
|
+
# reject. `lisa-github-write-issue` says a container needs no human gate.
|
|
558
|
+
# So a guard with only those two arms could be satisfied for a container only
|
|
559
|
+
# by writing something untrue, and BOTH untruths corrupt data another control
|
|
560
|
+
# reads: a stamped role puts a container in the build lane, and a fabricated
|
|
561
|
+
# hold marker is a state change with no inverse that reads to every later
|
|
562
|
+
# observer as a real product hold. A guard that can only be obeyed by lying is
|
|
563
|
+
# worse than one that simply refuses, because the lie is indistinguishable from
|
|
564
|
+
# a correct filing afterwards.
|
|
565
|
+
#
|
|
566
|
+
# WHY NOT `--label type:Epic`
|
|
567
|
+
#
|
|
568
|
+
# Because a declared type is a CLAIM, and one that costs a leaf nothing: the
|
|
569
|
+
# item it produces still looks buildable, so the exemption would be a free
|
|
570
|
+
# bypass of the readiness requirement for anything willing to mistype itself.
|
|
571
|
+
# That is the shape of this repository's measured case where an allowlist added
|
|
572
|
+
# to harden a guard became the way around it.
|
|
573
|
+
#
|
|
574
|
+
# WHAT IS READ INSTEAD
|
|
575
|
+
#
|
|
576
|
+
# The canonical container declaration the `derived-branch-plan` rule already
|
|
577
|
+
# defines and `lisa-github-write-issue` already stamps, in place of a Target
|
|
578
|
+
# Backend Environment:
|
|
579
|
+
#
|
|
580
|
+
# None — container: state rolls up from children
|
|
581
|
+
#
|
|
582
|
+
# It is a marker with no other meaning, so it is matched wherever the payload
|
|
583
|
+
# reaches — exactly the asymmetry `HUMAN_GATE_MARKER` gets, and for the same
|
|
584
|
+
# reason. Reading the declaration the writer already emits is also what keeps
|
|
585
|
+
# the guard and the skill from drifting apart a second time: there is one
|
|
586
|
+
# string, defined in one rule, and both read it.
|
|
587
|
+
#
|
|
588
|
+
# It is self-limiting in a way a type label is not. Writing it costs the item
|
|
589
|
+
# its Target Backend Environment and its Branch Plan — the two fields a leaf
|
|
590
|
+
# needs to be built at all — so the declaration is only useful to something
|
|
591
|
+
# that actually is a container. A leaf that writes it does not smuggle a
|
|
592
|
+
# buildable item past the gate; it files an unbuildable one, in the lane
|
|
593
|
+
# `leaf-only-lifecycle` and `lisa-repair-intake` already sweep.
|
|
594
|
+
#
|
|
595
|
+
# The WHOLE canonical value, `None` included, not just the distinctive tail.
|
|
596
|
+
# Dropping the leading half would match ordinary prose about how a container's
|
|
597
|
+
# state behaves — including a bug report ABOUT this guard — and a declaration
|
|
598
|
+
# that ordinary prose satisfies is not a declaration. Whitespace-tolerant
|
|
599
|
+
# because a body file may wrap the line; dash-tolerant because an author
|
|
600
|
+
# retyping it by hand will not always reach for the em dash.
|
|
601
|
+
CONTAINER_DECLARATION = re.compile(
|
|
602
|
+
r"None\s*[\u2014\u2013-]\s*container:\s*state\s+rolls\s+up\s+from\s+children",
|
|
603
|
+
re.IGNORECASE,
|
|
604
|
+
)
|
|
605
|
+
# The checkable half of "is it really a container". Provenance is unobservable
|
|
606
|
+
# here, as the header notes, but a CONTRADICTION is not: an item cannot be a
|
|
607
|
+
# container and a by-design leaf at once. Quoted from `leaf-only-lifecycle` —
|
|
608
|
+
# "the by-design leaf types (Bug, Task, Sub-task, Improvement)". Story and
|
|
609
|
+
# Spike are deliberately ABSENT: the same rule's childless-parent exception
|
|
610
|
+
# makes them leaf-or-container depending on child work, and a decomposition
|
|
611
|
+
# legitimately files a parent Story before the children that make it one.
|
|
612
|
+
BY_DESIGN_LEAF_TYPES = {"bug", "task", "sub-task", "subtask", "improvement"}
|
|
613
|
+
# Where a declared type can land on the created item. The label spellings are
|
|
614
|
+
# GitHub's `type:<value>` convention; `--type` and its aliases are the JIRA and
|
|
615
|
+
# Linear clients' spelling. Read with the same positional discipline as the
|
|
616
|
+
# build-ready role — a type named in a title or body is not a type applied.
|
|
617
|
+
TYPE_FLAGS = {
|
|
618
|
+
"--label", "--labels", "--add-label",
|
|
619
|
+
"--type", "--issue-type", "--issuetype",
|
|
620
|
+
}
|
|
531
621
|
POST_METHOD_FLAGS = {"-X", "--request", "--method"}
|
|
532
622
|
POST_PAYLOAD_FLAGS = {
|
|
533
623
|
"-d", "--data", "--data-raw", "--data-binary",
|
|
@@ -1433,6 +1523,65 @@ def declares_readiness(raw_args, roles, extra="", state_role_ok=False):
|
|
|
1433
1523
|
return False
|
|
1434
1524
|
|
|
1435
1525
|
|
|
1526
|
+
def declares_leaf_type(args):
|
|
1527
|
+
"""Whether the filing declares a by-design leaf type as a flag value.
|
|
1528
|
+
|
|
1529
|
+
Read only from positions that reach the created item, and never from a
|
|
1530
|
+
title or body — the same discipline the build-ready role is held to, for
|
|
1531
|
+
the same reason: a type named in prose is not a type applied.
|
|
1532
|
+
|
|
1533
|
+
Args:
|
|
1534
|
+
args: The creating command's arguments, already scoped to the flags.
|
|
1535
|
+
|
|
1536
|
+
Returns:
|
|
1537
|
+
True when a Bug / Task / Sub-task / Improvement is declared.
|
|
1538
|
+
"""
|
|
1539
|
+
for raw in flag_values(args, TYPE_FLAGS):
|
|
1540
|
+
for part in raw.split(","):
|
|
1541
|
+
value = part.strip().strip("'\"").lower()
|
|
1542
|
+
if value.startswith("type:"):
|
|
1543
|
+
value = value[len("type:") :]
|
|
1544
|
+
if value in BY_DESIGN_LEAF_TYPES:
|
|
1545
|
+
return True
|
|
1546
|
+
return False
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def declares_container(raw_args, texts=()):
|
|
1550
|
+
"""Whether the create declares the item a container.
|
|
1551
|
+
|
|
1552
|
+
A container is neither build-ready nor human-gated by construction, so this
|
|
1553
|
+
is the third accepted declaration rather than a third way to satisfy the
|
|
1554
|
+
first two. See the CONTAINER_DECLARATION block for why it reads the
|
|
1555
|
+
canonical declaration and not a `type:Epic` claim.
|
|
1556
|
+
|
|
1557
|
+
Args:
|
|
1558
|
+
raw_args: The creating command's arguments.
|
|
1559
|
+
texts: Payload or script text this command submits or runs.
|
|
1560
|
+
|
|
1561
|
+
Returns:
|
|
1562
|
+
True when the container declaration is present and uncontradicted.
|
|
1563
|
+
"""
|
|
1564
|
+
args = before_end_of_options(raw_args)
|
|
1565
|
+
# The contradiction check comes FIRST, so a filing that declares itself
|
|
1566
|
+
# both a container and a by-design leaf is refused rather than allowed on
|
|
1567
|
+
# the strength of the half that suits it.
|
|
1568
|
+
if declares_leaf_type(args):
|
|
1569
|
+
return False
|
|
1570
|
+
if CONTAINER_DECLARATION.search(" ".join(args)):
|
|
1571
|
+
return True
|
|
1572
|
+
for path in body_file_paths(args):
|
|
1573
|
+
try:
|
|
1574
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
1575
|
+
if CONTAINER_DECLARATION.search(handle.read()):
|
|
1576
|
+
return True
|
|
1577
|
+
except OSError:
|
|
1578
|
+
continue
|
|
1579
|
+
for text in texts:
|
|
1580
|
+
if text and CONTAINER_DECLARATION.search(text):
|
|
1581
|
+
return True
|
|
1582
|
+
return False
|
|
1583
|
+
|
|
1584
|
+
|
|
1436
1585
|
def flag_values(args, names):
|
|
1437
1586
|
"""Every value assigned to one of the named flags.
|
|
1438
1587
|
|
|
@@ -1880,6 +2029,15 @@ def scan(text, depth, from_file=False):
|
|
|
1880
2029
|
state_role_ok = tracker in STATE_ROLE_TRACKERS and target is None
|
|
1881
2030
|
if declares_readiness(args, roles, submitted, state_role_ok):
|
|
1882
2031
|
continue
|
|
2032
|
+
# The container arm. Checked after readiness, so it adds a
|
|
2033
|
+
# compliant filing where none existed and takes none away where one
|
|
2034
|
+
# already did. `text` joins the scan only for a creation found
|
|
2035
|
+
# INSIDE a file, where the declaration lives in the same file as
|
|
2036
|
+
# the create it answers for.
|
|
2037
|
+
if declares_container(
|
|
2038
|
+
args, (submitted, text) if from_file else (submitted,)
|
|
2039
|
+
):
|
|
2040
|
+
continue
|
|
1883
2041
|
# `LIFECYCLE_ROLE=ready curl …` puts the declaration BEFORE the
|
|
1884
2042
|
# client, which is where an inline assignment has to go, so the
|
|
1885
2043
|
# role is read from the whole segment rather than from the client's
|
|
@@ -304,6 +304,11 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
304
304
|
for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
|
|
305
305
|
const token = raw.replace(/^["']|["']$/gu, "");
|
|
306
306
|
if (!token.includes("/")) continue;
|
|
307
|
+
// A null base means an unliteral `cd` left the directory this script runs
|
|
308
|
+
// from unknown. An ABSOLUTE token is unambiguous either way; a relative one
|
|
309
|
+
// is exactly the thing that cannot be placed, so it is skipped rather than
|
|
310
|
+
// resolved against a directory picked for having been handy.
|
|
311
|
+
if (!isAbsolute(token) && cwd === null) continue;
|
|
307
312
|
const candidate = isAbsolute(token) ? token : join(cwd, token);
|
|
308
313
|
let root;
|
|
309
314
|
try {
|
|
@@ -317,6 +322,63 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
317
322
|
return null;
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
/**
|
|
326
|
+
* A `cd` target this guard cannot turn into a literal directory.
|
|
327
|
+
*
|
|
328
|
+
* Mirrors `parity-safety-net.sh`'s `follow_cd` test exactly, and the mirror is
|
|
329
|
+
* the point: two spellings of one rule are how the guards drift.
|
|
330
|
+
*/
|
|
331
|
+
const UNLITERAL_CD_TARGET = /[$`*?[{]/u;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The literal target of a leading `cd`, `""` for a bare `cd`, or null when the
|
|
335
|
+
* segment does not change directory.
|
|
336
|
+
*
|
|
337
|
+
* A bare `cd` is `cd "$HOME"`. Leaving the previous directory in place for it
|
|
338
|
+
* would resolve later tokens from a directory the shell has already left.
|
|
339
|
+
* @param segment - One statement from the command
|
|
340
|
+
* @returns The target text, or null when this segment is not a `cd`
|
|
341
|
+
*/
|
|
342
|
+
function cdTarget(segment) {
|
|
343
|
+
const tokens = tokenise(segment);
|
|
344
|
+
if (tokens.length === 0 || tokens[0] !== "cd") return null;
|
|
345
|
+
return tokens[1] ?? "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Apply one `cd` to the directory currently in force.
|
|
350
|
+
*
|
|
351
|
+
* Contract points 1-4 of `cwd-resolution-corpus.json`: a literal target
|
|
352
|
+
* REPLACES the directory rather than joining a list of candidates — the
|
|
353
|
+
* ordering was #3933's entire defect; a relative target composes against the
|
|
354
|
+
* directory in force; a target that is not literal text yields unknown; and
|
|
355
|
+
* unknown is sticky only until the next resolvable `cd`, because this returns
|
|
356
|
+
* a fresh answer for each one.
|
|
357
|
+
* @param current - Directory in force, or null when it is already unknown
|
|
358
|
+
* @param target - Literal `cd` target, `""` for a bare `cd`
|
|
359
|
+
* @returns The new directory, or null when it cannot be known
|
|
360
|
+
*/
|
|
361
|
+
function applyCd(current, target) {
|
|
362
|
+
const home = homedir();
|
|
363
|
+
let value = target === "" ? home : target;
|
|
364
|
+
if (value === "~") value = home;
|
|
365
|
+
else if (value.startsWith("~/")) value = join(home, value.slice(2));
|
|
366
|
+
if (
|
|
367
|
+
value === "" ||
|
|
368
|
+
value.startsWith("-") ||
|
|
369
|
+
UNLITERAL_CD_TARGET.test(value)
|
|
370
|
+
) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
if (!isAbsolute(value) && current === null) return null;
|
|
374
|
+
const next = isAbsolute(value) ? resolve(value) : resolve(current, value);
|
|
375
|
+
try {
|
|
376
|
+
return statSync(next).isDirectory() ? next : null;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
320
382
|
/**
|
|
321
383
|
* The foreign worktree an executed script reaches, or null.
|
|
322
384
|
*
|
|
@@ -340,41 +402,71 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
340
402
|
* disagreeing. The scripted form is the one neither covers, and it is the only
|
|
341
403
|
* cell this arm fills.
|
|
342
404
|
*
|
|
343
|
-
* ## A relative script path is resolved against
|
|
344
|
-
*
|
|
405
|
+
* ## A relative script path is resolved against the cwd the command will have
|
|
406
|
+
*
|
|
407
|
+
* A leading `cd` establishes the directory this guard resolves relative tokens
|
|
408
|
+
* from, because that is the directory the shell will be in once the command
|
|
409
|
+
* runs. Resolving against `payload.cwd` instead was CodySwannGT/lisa#3933's
|
|
410
|
+
* defect, inherited here knowingly and fixed in CodySwannGT/lisa#3952: on a
|
|
411
|
+
* machine running many worktrees of one repository, every tree holds its own
|
|
412
|
+
* copy of every script at the same relative path, and they differ by whatever
|
|
413
|
+
* each lane is doing.
|
|
345
414
|
*
|
|
346
|
-
*
|
|
347
|
-
* relative token is resolved against the hook process's working directory, not
|
|
348
|
-
* against the directory the command will have once it runs its own leading
|
|
349
|
-
* `cd`. So `cd <other tree> && bash scripts/foo.sh` reads THIS tree's copy of
|
|
350
|
-
* `scripts/foo.sh` and reports on it with full confidence.
|
|
415
|
+
* ## One contract, two implementations, and why that is the honest answer
|
|
351
416
|
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* the
|
|
357
|
-
*
|
|
417
|
+
* `parity-safety-net.sh` answers this same question — "which file will this
|
|
418
|
+
* command actually execute?" — in POSIX shell, where `follow_cd` mutates
|
|
419
|
+
* file-scope state that `follow_locate` reads. This guard is Node. **There is
|
|
420
|
+
* no call that reaches from here into a shell function's file-scope state**, so
|
|
421
|
+
* the duplication is not a factoring somebody skipped; it is one idea that
|
|
422
|
+
* cannot cross a language boundary as a function call.
|
|
358
423
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* When that lands, this call site should adopt whatever it produces.
|
|
424
|
+
* What binds the two is a written contract and a corpus of rows that BOTH
|
|
425
|
+
* guards are driven over in CI, so a divergence fails a test on the commit that
|
|
426
|
+
* causes it rather than surfacing later as a wrong verdict. Contract and corpus
|
|
427
|
+
* live in `tests/unit/hooks/support/cwd-resolution-corpus.json`.
|
|
364
428
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
429
|
+
* This is explicitly NOT unification, and CodySwannGT/lisa#3952 records it as
|
|
430
|
+
* such: two implementations remain. **A behaviour with no row in the corpus is
|
|
431
|
+
* free to diverge — add a row when you add a branch.**
|
|
432
|
+
*
|
|
433
|
+
* ## The two guards agree on the RESOLUTION, not on the verdict
|
|
434
|
+
*
|
|
435
|
+
* When the effective directory cannot be known, both guards decline to resolve
|
|
436
|
+
* the token. What they then do differs, and that difference is deliberate:
|
|
437
|
+
* `parity-safety-net.sh` refuses, because a destructive command it cannot see
|
|
438
|
+
* is the thing it exists to stop. This guard fails open, which is the doctrine
|
|
439
|
+
* every other undecidable case in this file follows. The corpus asserts the
|
|
440
|
+
* resolved path, not the exit code, precisely so that one contract can bind two
|
|
441
|
+
* guards whose fail directions are correctly different.
|
|
368
442
|
*
|
|
369
443
|
* Fails OPEN and says so, like every other undecidable case in this file.
|
|
370
444
|
*/
|
|
371
445
|
function scriptReachingForeignTree(payload, bound) {
|
|
372
446
|
const command = payload.tool_input?.command;
|
|
373
447
|
if (typeof command !== "string" || !command) return null;
|
|
448
|
+
let cwd = payload.cwd;
|
|
374
449
|
for (const segment of command.split(COMMAND_SEPARATORS)) {
|
|
450
|
+
const target = cdTarget(segment);
|
|
451
|
+
if (target !== null) {
|
|
452
|
+
cwd = applyCd(cwd, target);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
375
455
|
const named = executedPath(segment);
|
|
376
456
|
if (!named) continue;
|
|
377
|
-
|
|
457
|
+
if (!isAbsolute(named) && cwd === null) {
|
|
458
|
+
// Contract point 3: the effective directory is unknown, so WHICH copy of
|
|
459
|
+
// a same-named script would run is unknown too. Declining to resolve is
|
|
460
|
+
// the half shared with parity-safety-net.sh; failing open rather than
|
|
461
|
+
// refusing is this guard's own doctrine, and the corpus binds the two
|
|
462
|
+
// guards on the resolution precisely so it does not have to bind them
|
|
463
|
+
// on a verdict one of them would be wrong to reach.
|
|
464
|
+
say(
|
|
465
|
+
`cannot tell which directory ${named} would run from; NOT enforcing on it`
|
|
466
|
+
);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const path = isAbsolute(named) ? resolve(named) : resolve(cwd, named);
|
|
378
470
|
let text;
|
|
379
471
|
try {
|
|
380
472
|
if (statSync(path).size > SCRIPT_READ_LIMIT) {
|
|
@@ -386,7 +478,7 @@ function scriptReachingForeignTree(payload, bound) {
|
|
|
386
478
|
// Not a readable file: nothing was executed that this can read.
|
|
387
479
|
continue;
|
|
388
480
|
}
|
|
389
|
-
const foreign = foreignTreeIn(text, bound,
|
|
481
|
+
const foreign = foreignTreeIn(text, bound, cwd);
|
|
390
482
|
if (foreign) return { script: path, foreign };
|
|
391
483
|
}
|
|
392
484
|
return null;
|
|
@@ -6,10 +6,11 @@ Filing a work item without the ready role is an **incomplete handoff** — build
|
|
|
6
6
|
|
|
7
7
|
**An omitted `build_ready` is NOT build-ready on JIRA, GitHub, and Linear alike.** Ready is an explicit claim, never an accident of which tracker a project configured. This is a **breaking change** for GitHub and Linear callers, which previously treated omission as ready; `lisa-github-validate-issue` F4's compensating omitted → `true` normalization is removed with it.
|
|
8
8
|
|
|
9
|
-
## Every filing declares one of
|
|
9
|
+
## Every filing declares one of three things
|
|
10
10
|
|
|
11
11
|
- **`build_ready: true`** — complete enough to build; enters the ready lane for auto-pickup. Required for any complete defect found during other work, so it is claimable next cycle with no human flipping status.
|
|
12
12
|
- **`human_gate: "<why a human must judge this first>"`** — deliberately held outside the queue. The writer stamps a visible line plus `<!-- [lisa-human-gate] reason=<short-slug> -->` so the hold is auditable.
|
|
13
|
+
- **The container declaration** — `None — container: state rolls up from children`, the canonical line `derived-branch-plan` defines and every writer stamps in place of a Target Backend Environment. A container is never build-ready (`leaf-only-lifecycle`) and needs no gate, so it declares the third thing instead. Deliberately **not** keyed on `--label type:Epic`: a declared type is a claim that costs a leaf nothing and leaves it looking buildable, where the container line costs the item the environment and Branch Plan a leaf needs to be built. It is refused alongside a by-design leaf type (`type:Bug` / `type:Task` / `type:Sub-task` / `type:Improvement`) — an item cannot be a container and a leaf at once.
|
|
13
14
|
|
|
14
15
|
Filed, not ready, and no `human_gate` is the **incomplete handoff** case — writers reject it and name both ways to resolve it. `build_ready: false` with no reason is the same omission with a value attached. `build_ready` stays subordinate to `leaf-only-lifecycle`: a container is never build-ready and needs no gate.
|
|
15
16
|
|
|
@@ -21,7 +22,7 @@ Filed, not ready, and no `human_gate` is the **incomplete handoff** case — wri
|
|
|
21
22
|
|
|
22
23
|
## This rule is enforced
|
|
23
24
|
|
|
24
|
-
The PreToolUse guard `block-direct-issue-create.sh` refuses a direct tracker-creation command (`gh issue create`, `gh api` POST to an issues endpoint, `linear`/`jira`/`acli` creates, equivalent `curl` posts) that carries **
|
|
25
|
+
The PreToolUse guard `block-direct-issue-create.sh` refuses a direct tracker-creation command (`gh issue create`, `gh api` POST to an issues endpoint, `linear`/`jira`/`acli` creates, equivalent `curl` posts) that carries **none of the three declarations** — no configured build-ready role, no `[lisa-human-gate]` marker, and no container declaration. It shipped because this rule as prose did not bind: 13 of 13 issues filed during the session that merged it bypassed it, several by the agent that wrote it, while the one obligation backed by a git hook was honored 50 of 50 times. It stands down where no tracker is configured, and for an operator who set `LISA_ALLOW_DIRECT_ISSUE_CREATE` **in the ambient environment** — never from an inline assignment on the refused command.
|
|
25
26
|
|
|
26
27
|
**The role is the TARGET repository's, not the caller's.** A creation addressed at another repository — `--repo` / `-R`, or a `repos/<org>/<repo>/issues` endpoint — is judged against that repository's build-ready role: `hardening.upstreamReadyRole` (default `status:ready`) when the target is `hardening.upstreamRepo`, and the stock `status:ready` otherwise. A creation naming no repository, or naming the project's own `github.org`/`github.repo`, is judged against the project's own role exactly as before. A declaration is required either way; only its vocabulary changes.
|
|
27
28
|
|
|
@@ -22,7 +22,7 @@ So the same vendor-neutral `lisa-tracker-write` call produced a different lifecy
|
|
|
22
22
|
|
|
23
23
|
The safe direction is the implicit one. A ticket that reaches a build queue by accident is worse than one that waits, because the queue is what agents act on autonomously.
|
|
24
24
|
|
|
25
|
-
## Every filing declares one of two things
|
|
25
|
+
## Every filing declares one of two things — three, counting containers
|
|
26
26
|
|
|
27
27
|
A writer accepts a filing only when it carries **one** of:
|
|
28
28
|
|
|
@@ -38,6 +38,24 @@ Filed, not ready, and no `human_gate` is the **incomplete handoff** case: the wr
|
|
|
38
38
|
|
|
39
39
|
`build_ready` remains strictly subordinate to `leaf-only-lifecycle`: a container is never build-ready regardless of what a caller passes, and a container needs no `human_gate` because its state rolls up from its children rather than being claimed.
|
|
40
40
|
|
|
41
|
+
### The container declaration, on the direct-CLI path
|
|
42
|
+
|
|
43
|
+
That exemption used to live only in writer prose, which meant the guard below — the thing that actually runs — had no arm for it. A container could satisfy the guard only by declaring a build-ready role `leaf-only-lifecycle` forbids on it, or a human gate it does not have. **A guard that can only be obeyed by writing something untrue is worse than one that simply refuses**, because the lie is indistinguishable from a correct filing afterwards, and both available lies corrupt data another control reads: a stamped role puts a container in the lane build-intake claims from, and a fabricated hold marker is a state change with no inverse.
|
|
44
|
+
|
|
45
|
+
So a container filed directly through a tracker CLI declares a **third** thing — the canonical container line the `derived-branch-plan` rule already defines and every writer already stamps in place of a Target Backend Environment:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
## Target Backend Environment
|
|
49
|
+
|
|
50
|
+
None — container: state rolls up from children
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Not `--label type:Epic`.** A declared type is a claim, and one that costs a leaf nothing: the item it produces still looks buildable, so keying the exemption on it would hand any item willing to mistype itself a free bypass of the readiness requirement — the shape of the measured case where an allowlist added to harden a guard became the way around it. The container line is self-limiting instead: writing it costs the item the Target Backend Environment and Branch Plan a leaf needs to be built at all, so it is only useful to something that actually is a container.
|
|
54
|
+
|
|
55
|
+
The declaration is refused alongside a **by-design leaf type** declared as a flag value (`type:Bug` / `type:Task` / `type:Sub-task` / `type:Improvement`, or the `--type` spelling on a state-based tracker) — an item cannot be a container and a leaf at once. Story and Spike are deliberately not in that set: `leaf-only-lifecycle`'s childless-parent exception makes them leaf-or-container depending on child work, and a decomposition legitimately files a parent Story before the children that make it one.
|
|
56
|
+
|
|
57
|
+
One string, defined in one rule, read by the guard and stamped by the writers — which is what keeps this exemption from drifting back into prose only.
|
|
58
|
+
|
|
41
59
|
## Complete defects found during other work are filed build-ready
|
|
42
60
|
|
|
43
61
|
Any defect discovered while doing something else, which is complete enough to build, is filed through `lisa-track` / `lisa-tracker-write` with **explicit `build_ready: true`**. It must be claimable by build-intake on the next cycle with no human flipping status. "Complete enough to build" is the `work-item-definition-of-ready` bar — reproduction, observed-versus-expected, and Gherkin acceptance criteria — not a placeholder to be fleshed out later.
|
|
@@ -60,7 +78,7 @@ The rule shipped as prose first, and prose did not hold. A conformance audit of
|
|
|
60
78
|
|
|
61
79
|
The control is the PreToolUse Bash guard `block-direct-issue-create.sh`, shipped to every agent variant (Claude, Codex, Cursor, Copilot, agy, OpenCode) and to the host `scripts/lisa-hooks/` fallback. It refuses a direct tracker-creation command — `gh issue create`, `gh api` POST to an issues endpoint, `gh api graphql createIssue`, `linear issue create`, `jira issue create`, `acli … create`, and equivalent `curl` posts — when the command carries **no readiness declaration**.
|
|
62
80
|
|
|
63
|
-
What it checks is the artifact, not the caller. A Bash-level hook cannot observe call provenance: any provenance signal is settable by the very agent being governed, so a guard built on one would be theatre. So the guard asks whether the command about to run produces a correctly declared item — the configured build-ready role is applied, or a `[lisa-human-gate]` marker is present (inline, or in the `--body-file` the create is about to submit). That is precisely the machine-checkable content of this rule, and it lets the three writers through by construction, because they always stamp one.
|
|
81
|
+
What it checks is the artifact, not the caller. A Bash-level hook cannot observe call provenance: any provenance signal is settable by the very agent being governed, so a guard built on one would be theatre. So the guard asks whether the command about to run produces a correctly declared item — the configured build-ready role is applied, or a `[lisa-human-gate]` marker is present, or the container declaration is (each read inline, or in the `--body-file` the create is about to submit). That is precisely the machine-checkable content of this rule, and it lets the three writers through by construction, because they always stamp one.
|
|
64
82
|
|
|
65
83
|
Two ways out, both deliberate:
|
|
66
84
|
|
|
@@ -289,6 +289,22 @@ proceed — the marker still holds. Never file the label *instead of* the marker
|
|
|
289
289
|
|
|
290
290
|
If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their state rolls up from children, so they need neither.
|
|
291
291
|
|
|
292
|
+
**The container exemption is executable, not prose.** It used to live only in the sentence above,
|
|
293
|
+
which meant the `block-direct-issue-create.sh` guard — the control that actually runs on the CLI
|
|
294
|
+
path — had no arm for it, and a container could satisfy that guard only by declaring a build-ready
|
|
295
|
+
role `leaf-only-lifecycle` forbids on it or a human gate it does not have. A container now declares
|
|
296
|
+
a **third** thing, and this skill already writes it: the canonical Target Backend Environment value
|
|
297
|
+
`None — container: state rolls up from children`, defined once by `derived-branch-plan`. The guard
|
|
298
|
+
reads that one string, so there is nothing for the skill and the guard to disagree about.
|
|
299
|
+
|
|
300
|
+
Two consequences for a container written from here. Render the Target Backend Environment section
|
|
301
|
+
with that exact value — an absent section reads as *underivable*, not exempt, and leaves the guard
|
|
302
|
+
nothing to read. And do not put a **by-design leaf type** (`type:Bug` / `type:Task` /
|
|
303
|
+
`type:Sub-task` / `type:Improvement`) on a filing carrying that declaration: an item cannot be a
|
|
304
|
+
container and a leaf at once, and the guard refuses the combination. `type:Story` and `type:Spike`
|
|
305
|
+
are not in that set, because `leaf-only-lifecycle`'s childless-parent exception makes them
|
|
306
|
+
leaf-or-container depending on child work.
|
|
307
|
+
|
|
292
308
|
## Phase 5.5 — Validate (Pre-write Gate)
|
|
293
309
|
|
|
294
310
|
Before any write, invoke `lisa-github-validate-issue` with the full proposed spec assembled from Phases 2 / 3 / 4 / 5. Pass it as a YAML block per the `lisa-github-validate-issue` schema, including `runtime_behavior_change`, `authenticated_surface`, and `artifacts_attached` flags so the right gates run.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Refuse a cwd-resolution corpus that cannot bite.
|
|
4
|
+
*
|
|
5
|
+
* Two guards answer "which file will this command actually execute?" in two
|
|
6
|
+
* languages — POSIX shell in `parity-safety-net.sh`, Node in
|
|
7
|
+
* `worktree-binding-guard.mjs` — and one corpus of rows is what keeps their
|
|
8
|
+
* answers the same (CodySwannGT/lisa#3952). That arrangement is only worth
|
|
9
|
+
* anything while the rows are adversarial, and rows decay in one direction: a
|
|
10
|
+
* `holds` row is easy to write and always passes, so a corpus left alone drifts
|
|
11
|
+
* towards proving the resolver RAN rather than that it BITES.
|
|
12
|
+
*
|
|
13
|
+
* So the shape is enforced rather than requested:
|
|
14
|
+
*
|
|
15
|
+
* - both adversarial classes present, because a resolver is wrong in two
|
|
16
|
+
* directions and one class of row only ever catches one of them
|
|
17
|
+
* - every contract point carried by at least one adversarial row, because a
|
|
18
|
+
* point pinned only by rows both wrong implementations already pass is
|
|
19
|
+
* documented rather than pinned
|
|
20
|
+
* - at least one `unknown` expectation, because the fail-closed floor is the
|
|
21
|
+
* half nobody writes voluntarily
|
|
22
|
+
* - a row that skips a guard must say why, in the row, rather than by omission
|
|
23
|
+
*
|
|
24
|
+
* Exits 1 with the reason on any violation. Report-only otherwise.
|
|
25
|
+
* @module scripts/check-cwd-resolution-corpus
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
const CORPUS = path.resolve(
|
|
31
|
+
"tests/unit/hooks/support/cwd-resolution-corpus.json"
|
|
32
|
+
);
|
|
33
|
+
const GUARDS = ["shell", "node"];
|
|
34
|
+
const CONTROLS = ["bites", "wall", "holds"];
|
|
35
|
+
/** The classes that fail under a wrong implementation. `holds` does not. */
|
|
36
|
+
const ADVERSARIAL = ["bites", "wall"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read and parse the corpus.
|
|
40
|
+
* @returns The parsed corpus document
|
|
41
|
+
*/
|
|
42
|
+
function load() {
|
|
43
|
+
return JSON.parse(readFileSync(CORPUS, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every id appears once, so a row cannot be silently replaced by a copy.
|
|
48
|
+
* @param rows - Corpus rows
|
|
49
|
+
* @returns Violation messages
|
|
50
|
+
*/
|
|
51
|
+
function checkIds(rows) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const bad = [];
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
if (seen.has(row.id)) bad.push(`duplicate row id: ${row.id}`);
|
|
56
|
+
seen.add(row.id);
|
|
57
|
+
}
|
|
58
|
+
return bad;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Each row declares a known control class and at least one guard it applies to.
|
|
63
|
+
* @param rows - Corpus rows
|
|
64
|
+
* @returns Violation messages
|
|
65
|
+
*/
|
|
66
|
+
function checkShape(rows) {
|
|
67
|
+
const bad = [];
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
if (!CONTROLS.includes(row.control)) {
|
|
70
|
+
bad.push(`${row.id}: control must be one of ${CONTROLS.join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
const applies = row.applies ?? [];
|
|
73
|
+
if (applies.length === 0) bad.push(`${row.id}: applies to no guard`);
|
|
74
|
+
for (const guard of applies) {
|
|
75
|
+
if (!GUARDS.includes(guard))
|
|
76
|
+
bad.push(`${row.id}: unknown guard ${guard}`);
|
|
77
|
+
}
|
|
78
|
+
if (!applies.includes("node") && !row.why_not_node) {
|
|
79
|
+
bad.push(
|
|
80
|
+
`${row.id}: skips the Node guard without saying why. A row that skips a guard by omission is how a gap becomes invisible — add why_not_node.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return bad;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Both control classes are present, and the fail-closed floor is represented.
|
|
89
|
+
* @param rows - Corpus rows
|
|
90
|
+
* @returns Violation messages
|
|
91
|
+
*/
|
|
92
|
+
function checkControls(rows) {
|
|
93
|
+
const bad = [];
|
|
94
|
+
for (const control of ADVERSARIAL) {
|
|
95
|
+
if (rows.some(row => row.control === control)) continue;
|
|
96
|
+
bad.push(
|
|
97
|
+
control === "bites"
|
|
98
|
+
? 'no row has control "bites". Nothing here fails under a pre-fix implementation, so the silent direction — a confident verdict about a file nobody was about to run — is unguarded.'
|
|
99
|
+
: 'no row has control "wall". Nothing here fails under an over-eager implementation, so nothing stops the fail-closed floor becoming a wall, and a wall gets switched off.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (!rows.some(row => row.expect === "unknown")) {
|
|
103
|
+
bad.push(
|
|
104
|
+
'no row expects "unknown". The fail-closed floor is the half nobody writes voluntarily, so it is required.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return bad;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every contract point is carried by at least one row that a pre-fix
|
|
112
|
+
* implementation would fail.
|
|
113
|
+
* @param contract - Contract points, keyed by number
|
|
114
|
+
* @param rows - Corpus rows
|
|
115
|
+
* @returns Violation messages
|
|
116
|
+
*/
|
|
117
|
+
function checkCoverage(contract, rows) {
|
|
118
|
+
const bad = [];
|
|
119
|
+
for (const point of Object.keys(contract)) {
|
|
120
|
+
const covering = rows.filter(row =>
|
|
121
|
+
(row.contract ?? []).map(String).includes(point)
|
|
122
|
+
);
|
|
123
|
+
if (covering.length === 0) {
|
|
124
|
+
bad.push(
|
|
125
|
+
`contract point ${point} has no row. Add a row when you add a branch.`
|
|
126
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!covering.some(row => ADVERSARIAL.includes(row.control))) {
|
|
130
|
+
bad.push(
|
|
131
|
+
`contract point ${point} is carried only by "holds" rows, which both wrong implementations already pass. It is documented, not pinned.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return bad;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Report every violation at once, then exit.
|
|
140
|
+
* @returns Process exit code
|
|
141
|
+
*/
|
|
142
|
+
function main() {
|
|
143
|
+
const corpus = load();
|
|
144
|
+
const rows = corpus.rows ?? [];
|
|
145
|
+
const bad = [
|
|
146
|
+
...checkIds(rows),
|
|
147
|
+
...checkShape(rows),
|
|
148
|
+
...checkControls(rows),
|
|
149
|
+
...checkCoverage(corpus.contract ?? {}, rows),
|
|
150
|
+
];
|
|
151
|
+
if (bad.length > 0) {
|
|
152
|
+
process.stderr.write("cwd-resolution corpus is not adversarial enough:\n");
|
|
153
|
+
for (const line of bad) process.stderr.write(` - ${line}\n`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
const biting = rows.filter(row => ADVERSARIAL.includes(row.control)).length;
|
|
157
|
+
process.stdout.write(
|
|
158
|
+
`cwd-resolution corpus: ${rows.length} row(s), ${biting} of which a wrong implementation fails; ${Object.keys(corpus.contract).length} contract point(s) each pinned by at least one.\n`
|
|
159
|
+
);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
process.exit(main());
|