gentle-pi 1.2.0 → 2.0.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.
Files changed (107) hide show
  1. package/README.md +40 -9
  2. package/assets/agents/review-readability.md +0 -1
  3. package/assets/agents/review-reliability.md +0 -1
  4. package/assets/agents/review-resilience.md +0 -1
  5. package/assets/agents/review-risk.md +0 -1
  6. package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
  7. package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
  8. package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
  9. package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
  10. package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
  11. package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
  12. package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
  13. package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
  14. package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
  15. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
  16. package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
  17. package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
  18. package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
  19. package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
  20. package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
  21. package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
  22. package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
  23. package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
  24. package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
  25. package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
  26. package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
  27. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
  28. package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
  29. package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
  30. package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
  31. package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
  32. package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
  33. package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
  34. package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
  35. package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
  36. package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
  37. package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
  38. package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
  39. package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
  40. package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
  41. package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
  42. package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
  43. package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
  44. package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
  45. package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
  46. package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
  47. package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
  48. package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
  49. package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
  50. package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
  51. package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
  52. package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
  53. package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
  54. package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
  55. package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
  56. package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
  57. package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
  58. package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
  59. package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
  60. package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
  61. package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
  62. package/docs/native-authority-architecture.md +18 -3
  63. package/docs/review-integration.md +175 -27
  64. package/extensions/gentle-ai.ts +414 -19
  65. package/lib/gentle-ai-binary.ts +1 -1
  66. package/lib/native-review-cli.ts +679 -57
  67. package/lib/review-candidate-view.ts +131 -1
  68. package/lib/review-compact-contract.ts +12 -3
  69. package/lib/review-consent-latch.ts +51 -0
  70. package/lib/review-correction-lifecycle.ts +170 -0
  71. package/lib/review-integration-v2.ts +1563 -0
  72. package/package.json +15 -11
  73. package/runtime/gentle-ai-binary.mjs +1 -1
  74. package/runtime/native-review-cli.mjs +663 -41
  75. package/runtime/review-integration-v2.mjs +1564 -0
  76. package/scripts/build-git-commit-transaction-runner.mjs +1 -1
  77. package/scripts/gentle-ai-installer.mjs +16 -11
  78. package/scripts/install-gentle-ai.mjs +7 -3
  79. package/scripts/test-packed-runner.mjs +2 -2
  80. package/scripts/verify-package-files.mjs +216 -64
  81. package/skills/release/SKILL.md +38 -11
  82. package/tests/devbinary/native-review-parity.devtest.ts +348 -0
  83. package/tests/gentle-ai-binary.test.ts +8 -1
  84. package/tests/gentle-ai-installer.test.ts +24 -21
  85. package/tests/native-binary-gate.test.ts +145 -0
  86. package/tests/native-review-capability-contract.test.ts +87 -0
  87. package/tests/native-review-cli.test.ts +44 -1
  88. package/tests/native-review-parity-runtime.test.ts +70 -23
  89. package/tests/native-review-parity.test.ts +673 -0
  90. package/tests/native-risk-evidence-parity.test.ts +91 -0
  91. package/tests/package-manifest.test.ts +72 -7
  92. package/tests/review-actor-tool-deny.test.ts +20 -5
  93. package/tests/review-candidate-view.test.ts +331 -1
  94. package/tests/review-compact-contract.test.ts +5 -0
  95. package/tests/review-consent-latch.test.ts +67 -0
  96. package/tests/review-controller-native-recovery.test.ts +180 -5
  97. package/tests/review-controller-native-routing.test.ts +385 -12
  98. package/tests/review-controller-workspace-root.test.ts +26 -6
  99. package/tests/review-correction-lifecycle.test.ts +174 -0
  100. package/tests/review-integration-v2.test.ts +539 -0
  101. package/tests/runtime-harness.mjs +187 -0
  102. package/tests/support/native-binary-gate.ts +86 -0
  103. package/tests/verify-package-files.test.ts +119 -0
  104. package/lib/review-integration-v1.ts +0 -795
  105. package/runtime/review-integration-v1.mjs +0 -796
  106. package/tests/native-review-integration-v1.test.ts +0 -102
  107. package/tests/review-integration-v1.test.ts +0 -530
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from "node:assert/strict";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { createHash } from "node:crypto";
4
5
  import { existsSync } from "node:fs";
5
6
  import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
@@ -10,6 +11,7 @@ import { matchesKey } from "@earendil-works/pi-tui";
10
11
  import { fileURLToPath, pathToFileURL } from "node:url";
11
12
  import { stripAnsi } from "../lib/terminal-theme.ts";
12
13
  import { domainHashV1 } from "../lib/review-canonical.ts";
14
+ import { NativeReviewCliV216, NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError } from "../lib/native-review-cli.ts";
13
15
 
14
16
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
15
17
  const EXTENSIONS = [
@@ -171,6 +173,10 @@ function sha256(content) {
171
173
  return createHash("sha256").update(content).digest("hex");
172
174
  }
173
175
 
176
+ function gitSync(cwd, ...arguments_) {
177
+ return execFileSync("git", arguments_, { cwd, encoding: "utf8" }).trim();
178
+ }
179
+
174
180
  async function tempWorkspace() {
175
181
  return mkdtemp(join(tmpdir(), "gentle-pi-runtime-"));
176
182
  }
@@ -403,6 +409,187 @@ async function run() {
403
409
  await rm(toolCwd, { recursive: true, force: true });
404
410
  }
405
411
 
412
+ // review-candidate-view Phase 3.6 settling test: a contributor edit landing
413
+ // strictly between the controller-owned candidate binding and reviewer
414
+ // dispatch must diverge the live candidate tree from the frozen one, and
415
+ // dispatch must fail closed rather than expose a substituted view to the
416
+ // lens sub-agent. This drives the real `createGentleAiExtension` tool_call
417
+ // wiring (not the bare library function tested in
418
+ // tests/review-candidate-view.test.ts) with an injected candidate-view
419
+ // registry, so the actual production dispatch path is exercised.
420
+ const candidateDriftCwd = await tempWorkspace();
421
+ try {
422
+ const { createGentleAiExtension } = await import(
423
+ pathToFileURL(join(ROOT, "extensions/gentle-ai.ts")).href
424
+ );
425
+ const { CandidateViewRegistry } = await import(
426
+ pathToFileURL(join(ROOT, "lib/review-candidate-view.ts")).href
427
+ );
428
+
429
+ gitSync(candidateDriftCwd, "init", "-b", "main");
430
+ await writeFile(join(candidateDriftCwd, "tracked.txt"), "base\n");
431
+ gitSync(candidateDriftCwd, "add", "tracked.txt");
432
+ gitSync(
433
+ candidateDriftCwd,
434
+ "-c", "user.name=Runtime Harness",
435
+ "-c", "user.email=runtime-harness@example.invalid",
436
+ "commit", "-m", "base",
437
+ );
438
+
439
+ const registry = new CandidateViewRegistry();
440
+ const view = registry.create({ contributorRoot: candidateDriftCwd });
441
+ registry.bindCurrent({ token: view.token, lineageId: "harness-candidate-drift", selectedLenses: ["review-risk"] });
442
+
443
+ const dispatchPi = createPi();
444
+ createGentleAiExtension({ candidateViews: registry })(dispatchPi.pi);
445
+ const dispatchToolHook = dispatchPi.hooks.get("tool_call")[0];
446
+
447
+ // The contributor edits the tracked file strictly after the candidate
448
+ // view was bound (START) and strictly before dispatch would run.
449
+ await writeFile(join(candidateDriftCwd, "tracked.txt"), "drifted after bind, before dispatch\n");
450
+
451
+ const dispatchInput = { agent: "review-risk", task: "review", mode: "task" };
452
+ const dispatchResult = await dispatchToolHook(
453
+ { toolName: "subagent_run", input: dispatchInput },
454
+ createCtx(candidateDriftCwd),
455
+ );
456
+ assert.equal(dispatchResult?.block, true, "dispatch must fail closed when the candidate tree diverges between bind and dispatch");
457
+ assert.match(dispatchResult.reason, /live candidate|drift/i);
458
+ assert.equal(
459
+ dispatchInput.task,
460
+ "review",
461
+ "a failed-closed dispatch must never mutate the child dispatch input",
462
+ );
463
+ assert.doesNotMatch(
464
+ dispatchInput.task,
465
+ /Controller-owned review lineage/,
466
+ "a failed-closed dispatch must never inject a substituted candidate view into the lens sub-agent's task",
467
+ );
468
+ registry.cleanup(view.token);
469
+ } finally {
470
+ await rm(candidateDriftCwd, { recursive: true, force: true });
471
+ }
472
+
473
+ // Corrective v2 lifecycle settling test: the real controller must call the
474
+ // native evidence surface before it accepts a targeted-validation document.
475
+ // This is intentionally controller-level rather than a pure resolver test;
476
+ // the ordered call trace proves production wiring and the fail-closed gate.
477
+ const correctionCwd = await tempWorkspace();
478
+ try {
479
+ const { createGentleAiExtension } = await import(pathToFileURL(join(ROOT, "extensions/gentle-ai.ts")).href);
480
+ const { CandidateViewRegistry } = await import(pathToFileURL(join(ROOT, "lib/review-candidate-view.ts")).href);
481
+ gitSync(correctionCwd, "init", "-b", "main");
482
+ await writeFile(join(correctionCwd, "app.ts"), "export const value = 1;\n");
483
+ gitSync(correctionCwd, "add", "app.ts");
484
+ gitSync(correctionCwd, "-c", "user.name=Runtime Harness", "-c", "user.email=runtime-harness@example.invalid", "commit", "-m", "base");
485
+ await writeFile(join(correctionCwd, "app.ts"), "export const value = 2;\n");
486
+ const frozen = new CandidateViewRegistry().create({ contributorRoot: correctionCwd });
487
+ const sha = (digit) => `sha256:${digit.repeat(64)}`;
488
+ const repair = { schema: "gentle-ai.review-authority-repair-assessment/v1", status: "unsupported", counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 }, supportedOperations: ["review/complete-fix", "review/validate-fix"], authorizationSchema: "gentle-ai.review-repair-authorization/v1" };
489
+ const projection = { schema: "gentle-ai.review-integration.projection/v1", kind: "current-changes", projection: "workspace", baseTree: frozen.baseTree, initialReviewTree: frozen.candidateTree, currentCandidateTree: frozen.candidateTree, pathsDigest: sha("a"), paths: frozen.paths, intendedUntracked: [], intendedUntrackedProof: sha("b"), initialSnapshotIdentity: sha("c"), currentSnapshotIdentity: sha("c") };
490
+ const status = { contract: "gentle-ai.review-integration/v2", applicability: "current_target", authority: { version: "compact-v2", lineageId: "runtime-correction", state: "correction_required", generation: 1, revision: sha("d") }, receipt: { status: "expected_missing" }, action: "finalize", replayability: "not_replayable", frozen: { tier: "medium", originalChangedLines: 1, correctionBudget: 1 }, targetIdentity: sha("e"), projection, repair, candidates: [], nextTransition: { kind: "collect", reasonCode: "verification_evidence_required", collect: { inputs: [{ name: "verification_evidence", schema: "gentle-ai.review-verification-evidence/v2", captureOperation: "review.capture-evidence", arguments: [{ name: "lineage", value: "runtime-correction" }] }] } }, raw: {} };
491
+ const validationRequest = { schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: sha("f"), lineageId: "runtime-correction", expectedRevision: sha("d"), targetIdentity: sha("e"), fixFindingIds: [], projection: "workspace", correctionCandidateTree: frozen.candidateTree, correctionTargetIdentity: sha("e"), correctionPaths: frozen.paths, correctionPathsDigest: sha("a") };
492
+ const afterCapture = { ...status, authority: { ...status.authority, state: "validating" }, validationRequest, nextTransition: { kind: "collect", reasonCode: "targeted_validation_required", collect: { inputs: [{ name: "targeted_validation", schema: validationRequest.schema, captureOperation: "external.run_targeted_validation", arguments: [{ name: "lineage", value: "runtime-correction" }], validationRequest }] } } };
493
+ frozen.cleanup();
494
+ const calls = [];
495
+ let statuses = 0;
496
+ const nativeReviewCli = {
497
+ async targetStatus() { calls.push("status"); statuses += 1; return statuses === 1 ? status : afterCapture; },
498
+ async captureEvidence(request) { calls.push("capture-evidence"); return { schema: "gentle-ai.review-verification-evidence/v2", version: 2, lineageId: "runtime-correction", authorityRevision: sha("d"), targetIdentity: sha("e"), candidateTree: frozen.candidateTree, pathsDigest: sha("a"), paths: frozen.paths, ledgerIds: [], rawPayloadSha256: sha("1"), rawPayloadBytes: request.evidenceDocument.length, outcome: "passed", recordDigest: sha("2") }; },
499
+ async finalize() { calls.push("finalize"); return { lineageId: "runtime-correction", state: "approved", action: "approved", storeRevision: sha("3") }; },
500
+ async start() { throw new Error("not expected"); }, async validate() { throw new Error("not expected"); }, async bindSdd() { throw new Error("not expected"); }, async sddStatus() { return { ready: false }; }, async reviewStatus() { throw new Error("not expected"); },
501
+ };
502
+ const correctionPi = createPi();
503
+ createGentleAiExtension({ nativeReviewCli, candidateViews: new CandidateViewRegistry() })(correctionPi.pi);
504
+ const controller = correctionPi.tools.get("gentle_review");
505
+ const response = await controller.execute("runtime-correction", { operation: "finalize", lineageId: "runtime-correction", input: JSON.stringify({ final_evidence: "focused verification passed", final_verification_outcome: "passed", validation: { request_hash: "f".repeat(64), correction_ids: [], original_criteria: { passed: true, evidence: ["acceptance passes"] }, correction_regression: { passed: true, evidence: ["regression passes"] }, fix_caused_findings: [], follow_ups: [] } }) }, undefined, undefined, createCtx(correctionCwd));
506
+ assert.deepEqual(calls, ["status", "capture-evidence", "status", "finalize"]);
507
+ assert.equal(response.details.result.state, "approved");
508
+ } finally {
509
+ await rm(correctionCwd, { recursive: true, force: true });
510
+ }
511
+
512
+ // Task 11.2 (migrate-review-integration-v2): an unimplemented
513
+ // next_transition.execute.operation (e.g. a future "dispose-result") must
514
+ // raise a typed, named unsupported-transition-operation refusal and stop —
515
+ // never fall through to a generic schema-incompatible error, and never
516
+ // synthesize an invocation for it. Drives the real negotiated STATUS decode
517
+ // path (NativeReviewCliV216.targetStatus) against a stubbed adapter, so no
518
+ // live binary needs to advertise the unimplemented operation to prove this.
519
+ {
520
+ const capabilitiesFixturePath = join(
521
+ ROOT, "contracts", "review-integration", "v2", "fixtures", "capabilities.fixture.json",
522
+ );
523
+ const capabilitiesFixture = JSON.parse(await readFile(capabilitiesFixturePath, "utf8"));
524
+ const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
525
+ const capabilitiesBody = {
526
+ ...capabilitiesFixture,
527
+ package: { ...capabilitiesFixture.package, version: "2.2.2" },
528
+ };
529
+ const statusBody = {
530
+ schema: "gentle-ai.review-integration.status/v3",
531
+ contract: "gentle-ai.review-integration/v2",
532
+ operation: "review.status",
533
+ applicability: "unrelated",
534
+ receipt: { status: "not_applicable" },
535
+ action: "start",
536
+ replayability: "not_replayable",
537
+ target_identity: `sha256:${"a".repeat(64)}`,
538
+ repair: {
539
+ schema: "gentle-ai.review-authority-repair-assessment/v1",
540
+ status: "unsupported",
541
+ counts: { lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0, eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0 },
542
+ supported_operations: ["review/complete-fix", "review/validate-fix"],
543
+ authorization_schema: "gentle-ai.review-repair-authorization/v1",
544
+ },
545
+ projection: {
546
+ schema: "gentle-ai.review-integration.projection/v1",
547
+ kind: "current-changes",
548
+ projection: "workspace",
549
+ base_tree: "b".repeat(40),
550
+ initial_review_tree: "b".repeat(40),
551
+ current_candidate_tree: "b".repeat(40),
552
+ paths_digest: `sha256:${"a".repeat(64)}`,
553
+ paths: [],
554
+ intended_untracked: [],
555
+ intended_untracked_proof: `sha256:${"a".repeat(64)}`,
556
+ initial_snapshot_identity: `sha256:${"a".repeat(64)}`,
557
+ current_snapshot_identity: `sha256:${"a".repeat(64)}`,
558
+ },
559
+ candidates: [],
560
+ next_transition: {
561
+ kind: "execute",
562
+ reason_code: "disposable_result_pending",
563
+ execute: {
564
+ operation: "review.dispose-result",
565
+ arguments: [],
566
+ preconditions: [],
567
+ binding: { target_identity: `sha256:${"a".repeat(64)}` },
568
+ },
569
+ },
570
+ };
571
+ const calls = [];
572
+ const adapter = async (request) => {
573
+ calls.push(request.arguments);
574
+ const body = calls.length === 1 ? capabilitiesBody : statusBody;
575
+ return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
576
+ };
577
+ const client = new NativeReviewCliV216(
578
+ adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024,
579
+ async () => undefined, () => executableDigest,
580
+ );
581
+ let thrown;
582
+ try {
583
+ await client.targetStatus({ cwd: "/repo" });
584
+ } catch (error) {
585
+ thrown = error;
586
+ }
587
+ assert.ok(thrown instanceof NativeReviewCliError, "an unimplemented next_transition.execute.operation must raise a typed NativeReviewCliError");
588
+ assert.equal(thrown.code, NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION);
589
+ assert.match(thrown.message, /review\.dispose-result/, "the refusal must name the specific unimplemented operation");
590
+ assert.equal(calls.length, 2, "capabilities negotiation plus one STATUS call — never a third call synthesizing the unimplemented operation");
591
+ }
592
+
406
593
  const bannerCwd = await tempWorkspace();
407
594
  try {
408
595
  const ctx = createCtx(bannerCwd, true);
@@ -0,0 +1,86 @@
1
+ export const REQUIRE_NATIVE_BINARY_ENV = "GENTLE_PI_REQUIRE_NATIVE_BINARY";
2
+
3
+ export class NativeBinaryUnavailableError extends Error {
4
+ constructor(reason: string) {
5
+ super(reason);
6
+ this.name = "NativeBinaryUnavailableError";
7
+ }
8
+ }
9
+
10
+ interface RequireNativeBinaryInput {
11
+ resolvedBinary: string | undefined;
12
+ digestsPinned: boolean;
13
+ env: Record<string, string | undefined>;
14
+ }
15
+
16
+ interface NativeBinaryRun {
17
+ run: true;
18
+ }
19
+
20
+ interface NativeBinarySkip {
21
+ run: false;
22
+ reason: string;
23
+ }
24
+
25
+ type NativeBinaryGateResult = NativeBinaryRun | NativeBinarySkip;
26
+
27
+ // Two suites decide for themselves whether to run by skipping silently when
28
+ // the pinned native binary is unavailable. That is exactly how an earlier
29
+ // protocol-minor gap stayed hidden: the suite reported 0 fail while every
30
+ // test in it had skipped. This gate keeps the local convenience -- a
31
+ // developer without the binary still gets a printed skip reason -- but under
32
+ // `GENTLE_PI_REQUIRE_NATIVE_BINARY=1` (set in CI) the same condition throws
33
+ // instead, so CI cannot silently report green with zero real coverage.
34
+ export function requireNativeBinary(input: RequireNativeBinaryInput): NativeBinaryGateResult {
35
+ const armed = input.env[REQUIRE_NATIVE_BINARY_ENV] === "1";
36
+
37
+ if (input.resolvedBinary === undefined) {
38
+ const reason = `Native gentle-ai binary is unresolved; set ${REQUIRE_NATIVE_BINARY_ENV}=1 to fail instead of skip.`;
39
+ if (armed) throw new NativeBinaryUnavailableError(reason);
40
+ return { run: false, reason };
41
+ }
42
+
43
+ if (!input.digestsPinned) {
44
+ const reason = "Native gentle-ai release digest is not pinned; skipping the binary-verified suite.";
45
+ if (armed) throw new NativeBinaryUnavailableError(reason);
46
+ return { run: false, reason };
47
+ }
48
+
49
+ return { run: true };
50
+ }
51
+
52
+ export const REQUIRE_DEV_BINARY_ENV = "GENTLE_PI_REQUIRE_DEV_BINARY";
53
+
54
+ interface RequireDevBinaryInput {
55
+ devBinaryPath: string | undefined;
56
+ exists: boolean;
57
+ env: Record<string, string | undefined>;
58
+ }
59
+
60
+ // A THIRD suite decided for itself whether to run, the same silent-skip
61
+ // pattern the other two had (Phase 4): `tests/devbinary/native-review-parity.devtest.ts`
62
+ // skipped every test whenever GENTLE_AI_DEV_BINARY was unset, with no loud
63
+ // path back to a real failure. That silence is exactly how the v2.2.2
64
+ // terminology rot (Phase 13.12) went unnoticed: `pnpm run test:dev-binary`
65
+ // reported 5 tests / 0 pass / 0 fail for a long stretch. Gated on its OWN env
66
+ // var, distinct from GENTLE_PI_REQUIRE_NATIVE_BINARY: ordinary CI has no dev
67
+ // binary and must keep skipping by default; only an environment that
68
+ // specifically provisions one (a maintainer's local pre-release run, or a
69
+ // dedicated CI job) opts in to failing loudly instead.
70
+ export function requireDevBinary(input: RequireDevBinaryInput): NativeBinaryGateResult {
71
+ const armed = input.env[REQUIRE_DEV_BINARY_ENV] === "1";
72
+
73
+ if (input.devBinaryPath === undefined || input.devBinaryPath.length === 0) {
74
+ const reason = `GENTLE_AI_DEV_BINARY is unset; set it to an existing absolute gentle-ai binary path to run this journey, or set ${REQUIRE_DEV_BINARY_ENV}=1 to fail instead of skip.`;
75
+ if (armed) throw new NativeBinaryUnavailableError(reason);
76
+ return { run: false, reason };
77
+ }
78
+
79
+ if (!input.exists) {
80
+ const reason = `GENTLE_AI_DEV_BINARY names "${input.devBinaryPath}", which is not an existing absolute path; set ${REQUIRE_DEV_BINARY_ENV}=1 to fail instead of skip.`;
81
+ if (armed) throw new NativeBinaryUnavailableError(reason);
82
+ return { run: false, reason };
83
+ }
84
+
85
+ return { run: true };
86
+ }
@@ -0,0 +1,119 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ reconcileContractsOnDisk,
8
+ reconcileGeneratedRuntimeSources,
9
+ } from "../scripts/verify-package-files.mjs";
10
+
11
+ function makeFixtureRoot(): string {
12
+ return mkdtempSync(join(tmpdir(), "gentle-pi-verify-package-files-"));
13
+ }
14
+
15
+ test("contracts/ walk fails on a file that exists on disk but is unlisted in contractHashes", () => {
16
+ const fixtureRoot = makeFixtureRoot();
17
+ try {
18
+ const knownRelativePath = "contracts/review-integration/v2/schemas/known.schema.json";
19
+ const unlistedRelativePath = "contracts/review-integration/v2/schemas/unlisted.schema.json";
20
+ mkdirSync(join(fixtureRoot, "contracts/review-integration/v2/schemas"), { recursive: true });
21
+ writeFileSync(join(fixtureRoot, knownRelativePath), "{}\n");
22
+ writeFileSync(join(fixtureRoot, unlistedRelativePath), "{}\n");
23
+
24
+ const { unlistedOnDisk, listedButMissing } = reconcileContractsOnDisk(fixtureRoot, {
25
+ [knownRelativePath]: "irrelevant-for-this-walk",
26
+ });
27
+
28
+ assert.deepEqual(unlistedOnDisk, [unlistedRelativePath]);
29
+ assert.deepEqual(listedButMissing, []);
30
+ } finally {
31
+ rmSync(fixtureRoot, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ test("contracts/ walk fails on a contractHashes entry that is missing from disk", () => {
36
+ const fixtureRoot = makeFixtureRoot();
37
+ try {
38
+ const missingRelativePath = "contracts/review-integration/v2/schemas/missing.schema.json";
39
+ mkdirSync(join(fixtureRoot, "contracts/review-integration/v2/schemas"), { recursive: true });
40
+
41
+ const { unlistedOnDisk, listedButMissing } = reconcileContractsOnDisk(fixtureRoot, {
42
+ [missingRelativePath]: "irrelevant-for-this-walk",
43
+ });
44
+
45
+ assert.deepEqual(unlistedOnDisk, []);
46
+ assert.deepEqual(listedButMissing, [missingRelativePath]);
47
+ } finally {
48
+ rmSync(fixtureRoot, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test("sources <-> runtime/*.mjs walk fails when a generated runtime file is absent from the generator's sources array", () => {
53
+ const fixtureRoot = makeFixtureRoot();
54
+ try {
55
+ mkdirSync(join(fixtureRoot, "runtime"), { recursive: true });
56
+ writeFileSync(join(fixtureRoot, "runtime/known.mjs"), "// generated\n");
57
+ writeFileSync(join(fixtureRoot, "runtime/orphan.mjs"), "// generated\n");
58
+
59
+ const { drifted } = reconcileGeneratedRuntimeSources(
60
+ fixtureRoot,
61
+ ["known"],
62
+ ["runtime/known.mjs", "runtime/orphan.mjs"],
63
+ );
64
+
65
+ assert.deepEqual(drifted, [
66
+ { name: "orphan", inSources: false, inRuntimeDir: true, inRequiredPaths: true },
67
+ ]);
68
+ } finally {
69
+ rmSync(fixtureRoot, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ test("sources <-> runtime/*.mjs walk fails when a sources entry is absent from requiredPaths", () => {
74
+ const fixtureRoot = makeFixtureRoot();
75
+ try {
76
+ mkdirSync(join(fixtureRoot, "runtime"), { recursive: true });
77
+ writeFileSync(join(fixtureRoot, "runtime/known.mjs"), "// generated\n");
78
+ writeFileSync(join(fixtureRoot, "runtime/unrequired.mjs"), "// generated\n");
79
+
80
+ const { drifted } = reconcileGeneratedRuntimeSources(
81
+ fixtureRoot,
82
+ ["known", "unrequired"],
83
+ ["runtime/known.mjs"],
84
+ );
85
+
86
+ assert.deepEqual(drifted, [
87
+ { name: "unrequired", inSources: true, inRuntimeDir: true, inRequiredPaths: false },
88
+ ]);
89
+ } finally {
90
+ rmSync(fixtureRoot, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test("both walks report no drift when sources, runtime/*.mjs, requiredPaths, and contractHashes fully agree", () => {
95
+ const fixtureRoot = makeFixtureRoot();
96
+ try {
97
+ mkdirSync(join(fixtureRoot, "contracts/review-integration/v2/schemas"), { recursive: true });
98
+ mkdirSync(join(fixtureRoot, "runtime"), { recursive: true });
99
+ writeFileSync(
100
+ join(fixtureRoot, "contracts/review-integration/v2/schemas/status.schema.json"),
101
+ "{}\n",
102
+ );
103
+ writeFileSync(join(fixtureRoot, "runtime/known.mjs"), "// generated\n");
104
+
105
+ const contractsResult = reconcileContractsOnDisk(fixtureRoot, {
106
+ "contracts/review-integration/v2/schemas/status.schema.json": "irrelevant-for-this-walk",
107
+ });
108
+ const sourcesResult = reconcileGeneratedRuntimeSources(
109
+ fixtureRoot,
110
+ ["known"],
111
+ ["runtime/known.mjs"],
112
+ );
113
+
114
+ assert.deepEqual(contractsResult, { unlistedOnDisk: [], listedButMissing: [] });
115
+ assert.deepEqual(sourcesResult, { drifted: [] });
116
+ } finally {
117
+ rmSync(fixtureRoot, { recursive: true, force: true });
118
+ }
119
+ });