@amityco/social-plus-vise 1.3.0 → 1.4.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 (45) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/README.md +19 -9
  3. package/dist/capabilities.js +29 -1
  4. package/dist/entryState.js +71 -0
  5. package/dist/experience.js +70 -0
  6. package/dist/explore.js +1 -1
  7. package/dist/flow.js +382 -0
  8. package/dist/humanFormat.js +25 -0
  9. package/dist/intake.js +117 -0
  10. package/dist/intelligence/placement.js +2 -1
  11. package/dist/outcomes.js +126 -28
  12. package/dist/productExpectations.js +15 -0
  13. package/dist/requestReadiness.js +99 -0
  14. package/dist/server.js +566 -19
  15. package/dist/sidecar.js +5 -0
  16. package/dist/solutionPath.js +1 -1
  17. package/dist/tools/compliance.js +752 -125
  18. package/dist/tools/creative.js +14 -12
  19. package/dist/tools/design.js +321 -11
  20. package/dist/tools/experienceCompiler.js +1 -1
  21. package/dist/tools/experienceSensors.js +1 -1
  22. package/dist/tools/harness.js +13 -0
  23. package/dist/tools/integration.js +263 -90
  24. package/dist/tools/learning.js +1 -3
  25. package/dist/tools/project.js +963 -66
  26. package/dist/tools/smoke.js +134 -0
  27. package/dist/tools/uxHarness.js +54 -6
  28. package/dist/uikitCustomization.js +3 -1
  29. package/dist/version.js +7 -3
  30. package/package.json +1 -1
  31. package/packages/intelligence/catalog/catalog.schema.json +1 -1
  32. package/packages/intelligence/catalog/experience-objects.json +2 -2
  33. package/packages/intelligence/catalog/variants.json +24 -0
  34. package/rules/event.yaml +3 -0
  35. package/rules/feed.yaml +251 -0
  36. package/rules/invitation.yaml +4 -0
  37. package/rules/live-data.yaml +110 -0
  38. package/rules/notification-tray.yaml +4 -0
  39. package/rules/poll.yaml +5 -0
  40. package/rules/sdk-lifecycle.yaml +559 -0
  41. package/rules/search.yaml +5 -0
  42. package/rules/security.yaml +12 -0
  43. package/rules/story.yaml +5 -0
  44. package/rules/user-blocking.yaml +5 -0
  45. package/skills/social-plus-vise/SKILL.md +163 -15
@@ -2,11 +2,16 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import { sidecarDir } from "../sidecar.js";
5
6
  import { fileURLToPath } from "node:url";
6
- import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, optionalCapabilityChecklist, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
7
- import { CLASSIFY_ORDER, getOutcomeDefinition, hasAnswer, planContextFor, resolveOutcome, } from "../outcomes.js";
7
+ import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
8
+ import { CLASSIFY_ORDER, getOutcomeDefinition, planContextFor, resolveOutcome, } from "../outcomes.js";
8
9
  import { contractRuleCandidatesForPublicId, hasMultipleContractRuleCandidates, productExpectationBindingForSensor, productExpectationTitle, publicProductRuleId, } from "../productExpectations.js";
9
10
  import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
11
+ import { assembleIntake } from "../intake.js";
12
+ import { clarifyDimensions, clarifyGuidance } from "../requestReadiness.js";
13
+ import { recommendSolutionPath } from "../solutionPath.js";
14
+ import { recommendUIKitCustomization } from "../uikitCustomization.js";
10
15
  import { packageVersion } from "../version.js";
11
16
  import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
12
17
  import { installedBlockProvidedCapabilities } from "./blocks.js";
@@ -335,6 +340,26 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
335
340
  const creativeSelection = storedCreativeSelection && creativeSelectionAppliesToRequest(storedCreativeSelection, request)
336
341
  ? storedCreativeSelection
337
342
  : undefined;
343
+ if (options.allowUnresolvedIntake !== true) {
344
+ const platformDetected = inspection.platforms.length > 0;
345
+ const initSolutionPath = recommendSolutionPath(request, answers);
346
+ const initUikit = recommendUIKitCustomization({ request, answers, platform, solutionPath: initSolutionPath });
347
+ const clarify = clarifyGuidance(clarifyDimensions({
348
+ outcome,
349
+ platformDetected,
350
+ uikitGuidedUnknown: platformDetected && outcome === "unknown" && Boolean(initUikit),
351
+ creativeSelected: Boolean(creativeSelection),
352
+ }));
353
+ if (clarify) {
354
+ return {
355
+ status: "needs-clarification",
356
+ exitCode: 7,
357
+ outcome,
358
+ clarify,
359
+ nextStep: `${clarify.nextStep} (Or pass --allow-unresolved-intake for retrospective/harness initialization.)`,
360
+ };
361
+ }
362
+ }
338
363
  const uxHarness = creativeSelection
339
364
  ? await buildUxHarness({ repoPath: repoRoot, surfacePath, selection: creativeSelection, write: true })
340
365
  : undefined;
@@ -356,15 +381,31 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
356
381
  designSignals: inspection.designSignals,
357
382
  });
358
383
  if (intake.remainingBlocking > 0 && !intake.acknowledged_unresolved_blocking) {
359
- const blockingIds = intake.questions
360
- .filter((question) => question.blocksImplementationWhenMissing)
361
- .map((question) => question.id);
384
+ const blockingQuestions = intake.questions.filter((question) => question.blocksImplementationWhenMissing);
385
+ const blockingIds = blockingQuestions.map((question) => question.id);
362
386
  const answerExample = blockingIds.map((id) => `${id}=<value>`).join(" --answer ");
363
387
  return {
364
388
  status: "needs-clarification",
365
389
  exitCode: 7,
366
390
  outcome,
367
391
  intake,
392
+ decisionChecklist: blockingQuestions.map((question) => ({
393
+ id: question.id,
394
+ question: question.question,
395
+ why: question.why,
396
+ options: question.options,
397
+ })),
398
+ unattended: {
399
+ status: "blocked",
400
+ mode: "handoff-required",
401
+ reason: "Blocking intake answers are required before this surface can be initialized.",
402
+ instruction: "If the user is not available, stop and return the blocking intake questions. Do not invent scope, route, identity, region, design, or capability answers.",
403
+ allowedActions: ["read-only inspect/plan output", "collect exact --answer values from the user"],
404
+ resumeWhen: [
405
+ "every blocking intake id has an explicit user-provided answer",
406
+ "init is re-run with one repeated --answer id=value flag per blocking id",
407
+ ],
408
+ },
368
409
  ...(intakeWarnings.length > 0 && { warnings: intakeWarnings }),
369
410
  nextStep: "Run `vise plan` and surface the blocking intake questions to the customer. " +
370
411
  `Then re-run with their answers (one --answer per id): vise init --request <request> --answer ${answerExample}. ` +
@@ -408,6 +449,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
408
449
  });
409
450
  await writeJson(path.join(sidecarDir(repoRoot), "inspection.json"), inspection);
410
451
  await writeFile(path.join(sidecarDir(repoRoot), "README.md"), sidecarReadme(compliance), "utf8");
452
+ const runtimeSmokeTemplate = await writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome);
411
453
  const checkSnapshot = await checkCompliance(repoPath);
412
454
  await writeJson(path.join(sidecarDir(repoRoot), "findings.json"), {
413
455
  snapshot_at: compliance.generated_at,
@@ -434,6 +476,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
434
476
  ...(uxHarness ? { ux_harness: uxHarnessSummary(uxHarness) } : {}),
435
477
  ...(compliance.design_contract && { design_contract: compliance.design_contract }),
436
478
  ...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
479
+ ...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
437
480
  intake: {
438
481
  status: intake.status,
439
482
  remainingBlocking: intake.remainingBlocking,
@@ -442,7 +485,127 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
442
485
  answers: intake.answers,
443
486
  },
444
487
  ...(warnings.length > 0 && { warnings }),
445
- nextStep: "Run vise check, then implement until rules pass deterministically or are attested.",
488
+ nextStep: runtimeSmokeTemplate
489
+ ? "Run vise check, implement until rules pass, then cold-launch the app, capture VISE_SMOKE logs, run vise smoke, and rerun vise check before completing the surface."
490
+ : "Run vise check, then implement until rules pass deterministically or are attested.",
491
+ };
492
+ }
493
+ function runtimeSmokeSurfaceForOutcome(outcome) {
494
+ switch (outcome) {
495
+ case "add-community":
496
+ return {
497
+ id: "community",
498
+ expect: "populated",
499
+ launch: "Cold-launch into the product route that lists/selects the current community target.",
500
+ };
501
+ case "add-feed":
502
+ return {
503
+ id: "feed",
504
+ expect: "populated",
505
+ launch: "Cold-launch into the product route that mounts the selected feed/post collection.",
506
+ };
507
+ case "add-comments":
508
+ return {
509
+ id: "comments",
510
+ expect: "populated",
511
+ launch: "Cold-launch into a product post/thread route with a visible parent post and comment collection.",
512
+ };
513
+ case "add-chat":
514
+ return {
515
+ id: "chat",
516
+ expect: "populated",
517
+ launch: "Cold-launch into the product inbox or selected channel route with readable messages.",
518
+ };
519
+ case "add-follow":
520
+ return {
521
+ id: "profile",
522
+ expect: "populated",
523
+ launch: "Cold-launch into a product profile/member route with relationship data visible.",
524
+ };
525
+ case "add-notifications":
526
+ return {
527
+ id: "notifications",
528
+ expect: "resolved",
529
+ launch: "Cold-launch into the product notification tray/settings route.",
530
+ };
531
+ default:
532
+ return null;
533
+ }
534
+ }
535
+ function runtimeSmokeCaptureRecipe(platform) {
536
+ const normalized = platform.toLowerCase();
537
+ if (normalized === "android") {
538
+ return {
539
+ log: "adb logcat",
540
+ steps: [
541
+ "adb logcat -c",
542
+ "Cold-launch the app into the social route and wait until the visible collection resolves.",
543
+ "adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log",
544
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
545
+ ],
546
+ };
547
+ }
548
+ if (normalized === "ios") {
549
+ return {
550
+ log: "simctl unified log",
551
+ steps: [
552
+ "xcrun simctl launch --terminate-running-process <device> <bundle-id>",
553
+ "Wait until the visible collection resolves.",
554
+ "xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log",
555
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
556
+ ],
557
+ };
558
+ }
559
+ if (normalized === "flutter") {
560
+ return {
561
+ log: "flutter run logs or adb logcat",
562
+ steps: [
563
+ "Use an ignored define file such as .dart_tool/vise-runtime-defines.env for runtime values.",
564
+ "Cold-launch the app and wait until the collection resolves.",
565
+ "Capture debugPrint VISE_SMOKE lines from flutter run logs or adb logcat into sp-vise/evidence/runtime-smoke.log.",
566
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
567
+ ],
568
+ };
569
+ }
570
+ if (normalized === "react-native") {
571
+ return {
572
+ log: "Metro/device logs",
573
+ steps: [
574
+ "Cold-launch the app and wait until the collection resolves.",
575
+ "Capture console.info VISE_SMOKE lines from Metro or adb logcat into sp-vise/evidence/runtime-smoke.log.",
576
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
577
+ ],
578
+ };
579
+ }
580
+ return {
581
+ log: "browser or app runtime log",
582
+ steps: [
583
+ "Cold-load the route that mounts the collection surface.",
584
+ "Capture console.info VISE_SMOKE lines into sp-vise/evidence/runtime-smoke.log.",
585
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
586
+ ],
587
+ };
588
+ }
589
+ async function writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome) {
590
+ const surface = runtimeSmokeSurfaceForOutcome(outcome);
591
+ if (!surface)
592
+ return null;
593
+ const smokePath = path.join(sidecarDir(repoRoot), "smoke.json");
594
+ if (existsSync(smokePath)) {
595
+ return { status: "existing", path: "sp-vise/smoke.json", surface: surface.id };
596
+ }
597
+ const platformName = platform || "unknown";
598
+ await writeJson(smokePath, {
599
+ platform: platformName,
600
+ surfaces: [surface],
601
+ capture: runtimeSmokeCaptureRecipe(platformName),
602
+ });
603
+ return {
604
+ status: "created",
605
+ path: "sp-vise/smoke.json",
606
+ surface: surface.id,
607
+ expect: surface.expect,
608
+ capture: runtimeSmokeCaptureRecipe(platformName),
446
609
  };
447
610
  }
448
611
  function intakeAuditFor(args) {
@@ -455,73 +618,17 @@ function intakeAuditFor(args) {
455
618
  designSignals: args.designSignals,
456
619
  answers: args.answers,
457
620
  });
458
- const questions = [...getOutcomeDefinition(args.outcome).intakeQuestions(ctx)];
459
- if (args.designReview?.status === "needs-confirmation" && !hasAnswer(ctx.answers, DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID)) {
460
- questions.push({
461
- id: DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID,
462
- question: `Open ${args.designReview.previewPath} and confirm whether this design preview/contract should be used for the social UI.`,
463
- why: "A design contract must be explicitly accepted before Vise can feed it forward or let the agent claim design conformance. If it is wrong, reject it and provide the correct design source.",
464
- required: true,
465
- blocksImplementationWhenMissing: true,
466
- options: ["yes", "no"],
467
- });
468
- }
469
- if (args.designReview?.status === "rejected" && !hasAnswer(ctx.answers, "design_source")) {
470
- questions.push({
471
- id: "design_source",
472
- question: "What design source should replace the rejected preview/contract?",
473
- why: "The current design preview was rejected, so Vise must not use it. Provide a prototype, screenshot, theme/token file, or explicitly say to proceed without a design-conformance claim.",
474
- required: true,
475
- blocksImplementationWhenMissing: true,
476
- });
477
- }
478
- if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
479
- questions.push({
480
- id: "design_source",
481
- question: "Where are the app's design tokens, theme, or reusable UI components defined?",
482
- why: "The user asked to match the existing design, and no local design source was detected.",
483
- required: true,
484
- blocksImplementationWhenMissing: true,
485
- });
486
- }
487
- if (ctx.mentionsDesign && ctx.designSignals.length > 0 && !hasAnswer(ctx.answers, "confirm_design_source")) {
488
- questions.push({
489
- id: "confirm_design_source",
490
- question: `Should the social UI use the detected design source(s): ${ctx.designSignals.map((signal) => signal.file).join(", ")}?`,
491
- why: "Vise found likely design evidence, but the user or host agent should confirm it is the right source before UI edits.",
492
- required: true,
493
- blocksImplementationWhenMissing: false,
494
- options: ["yes", "use another source"],
495
- });
496
- }
497
- if (args.designBrief &&
498
- args.designReview?.status === "accepted" &&
499
- (args.outcome === "add-feed" || args.outcome === "add-chat") &&
500
- !args.designBrief.roles.some((role) => role.role === "primaryAction") &&
501
- !hasAnswer(ctx.answers, "primary_action_token")) {
502
- questions.push({
503
- id: "primary_action_token",
504
- question: "Which design token (or color value) should be used as the primary action color? No primary-action token was confidently identified in the design contract.",
505
- why: "A primary action colour is needed for interactive elements (composer button, own-message bubble). Without a confident token, the agent must guess or omit it.",
506
- required: false,
507
- blocksImplementationWhenMissing: false,
508
- });
509
- }
510
- const availableOptionalIds = availableOptionalCapabilityIds(args.capabilityAvailability);
511
- const optionalChoices = args.outcome === "add-feed" ? optionalCapabilityChecklist(args.outcome, availableOptionalIds) : [];
512
- if (args.outcome === "add-feed" && optionalChoices.length > 0 && !hasAnswer(ctx.answers, "feed_optional_capabilities")) {
513
- questions.push({
514
- id: "feed_optional_capabilities",
515
- question: `Which optional feed capabilities should be in scope on ${platform}: ${optionalChoices.map((capability) => capability.id).join(", ")}, or none?`,
516
- why: "These capabilities are useful for full feeds, but should become enforceable only after the customer explicitly opts in. Vise only lists capabilities that appear available in the platform SDK surface; unsupported capabilities must be treated as out of scope or confirmed from docs.",
517
- required: false,
518
- blocksImplementationWhenMissing: false,
519
- options: ["none", ...optionalChoices.map((capability) => capability.id)],
520
- });
521
- }
522
- const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
621
+ const outcomeQuestions = getOutcomeDefinition(args.outcome).intakeQuestions(ctx);
622
+ const { questions, remainingBlocking, status } = assembleIntake({
623
+ ctx,
624
+ outcomeQuestions,
625
+ outcome: args.outcome,
626
+ designBrief: args.designBrief,
627
+ capabilityAvailability: args.capabilityAvailability,
628
+ designReview: args.designReview,
629
+ });
523
630
  return {
524
- status: remainingBlocking > 0 ? "needs-clarification" : "ready",
631
+ status,
525
632
  questions,
526
633
  answers: args.answers,
527
634
  remainingBlocking,
@@ -755,17 +862,42 @@ export async function checkCompliance(repoPath, options = {}) {
755
862
  const recordedPlatforms = compliance.surface?.platforms || [];
756
863
  const platformsToValidate = Array.from(new Set([...detectedPlatforms, ...recordedPlatforms]));
757
864
  const platforms = platformsToValidate.length > 0 ? platformsToValidate : ["unknown"];
865
+ if (compliance.rules.length === 0) {
866
+ const expectedRules = await applicableRules(compliance.outcome, platforms);
867
+ if (expectedRules.length > 0) {
868
+ return {
869
+ status: "contract-drift",
870
+ exitCode: 4,
871
+ outcome: compliance.outcome,
872
+ surfacePath: compliance.surface?.path,
873
+ summary: { "contract-drift": 1 },
874
+ rules: [
875
+ {
876
+ ruleId: "contract.ruleset",
877
+ title: "Compliance ruleset is empty but a social.plus integration was detected",
878
+ severity: "error",
879
+ status: "stale",
880
+ reason: `sp-vise/compliance.json baked 0 compliance rules, but a social.plus integration was detected for platform(s) ${platforms.join(", ")} (${expectedRules.length} rule(s) apply). The sidecar was initialized before the platform was detectable (e.g. a CDN / no-package.json integration whose SDK usage is only in source) or is stale — a green here would certify unverified SDK code. Re-run \`vise init\` to bake the ruleset, then \`vise check\`.`,
881
+ },
882
+ ],
883
+ };
884
+ }
885
+ }
758
886
  const { canonicalPlatform } = await import("./sdkFacts.js");
759
887
  const allFindings = await Promise.all(platforms.map((p) => validateSetup(inspection.effectiveRoot, p)));
760
888
  const findings = allFindings.flat();
761
889
  const findingsById = new Map();
890
+ const findingsByIdAll = new Map();
762
891
  for (const finding of findings) {
763
892
  findingsById.set(finding.ruleId, finding);
893
+ appendFinding(findingsByIdAll, finding.ruleId, finding);
764
894
  if (finding.sensorId) {
765
895
  findingsById.set(finding.sensorId, finding);
896
+ appendFinding(findingsByIdAll, finding.sensorId, finding);
766
897
  }
767
898
  }
768
899
  const attestations = await readAttestations(repoRoot);
900
+ const attestationHygiene = await readAttestationHygiene(repoRoot, compliance);
769
901
  const results = [];
770
902
  for (const ref of compliance.rules) {
771
903
  const rule = rules.get(ref.rule_id);
@@ -794,20 +926,18 @@ export async function checkCompliance(repoPath, options = {}) {
794
926
  }
795
927
  const hasDeterministicChecks = (rule.enforcement.deterministic ?? []).length > 0;
796
928
  const isInferential = !hasDeterministicChecks && !!rule.enforcement.inferential;
797
- const finding = hasDeterministicChecks ? deterministicFinding(rule, findingsById) : undefined;
798
- if (hasDeterministicChecks && !finding) {
799
- const identity = checkRuleIdentity(rule.id);
800
- const rulePlatform = identity.validator?.platform;
801
- const passBasis = rulePlatform && !platforms.some((p) => canonicalPlatform(p) === canonicalPlatform(rulePlatform))
802
- ? "undetected-platform"
803
- : "absent";
929
+ const deterministic = hasDeterministicChecks
930
+ ? await assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot)
931
+ : undefined;
932
+ const finding = deterministic?.finding;
933
+ if (hasDeterministicChecks && deterministic?.passed) {
804
934
  results.push({
805
- ...identity,
935
+ ...checkRuleIdentity(rule.id),
806
936
  title: rule.title,
807
937
  severity: rule.severity,
808
938
  status: "deterministic-pass",
809
- pass_basis: passBasis,
810
- evidence: { source: "validate_setup", finding_absent: deterministicFindingIds(rule) },
939
+ pass_basis: deterministic.pass_basis,
940
+ evidence: deterministic.evidence,
811
941
  });
812
942
  continue;
813
943
  }
@@ -823,10 +953,10 @@ export async function checkCompliance(repoPath, options = {}) {
823
953
  reason: rule.advisory
824
954
  ? "Advisory: informational only — does not affect compliance status."
825
955
  : rule.enforcement.attestation.allowed
826
- ? "Current deterministic check failed; previously synced deterministic-pass evidence is stale."
827
- : "Current deterministic check failed; this rule does not allow attestation.",
956
+ ? `Current deterministic check failed; previously synced deterministic-pass evidence is stale.${deterministic?.reason ? ` ${deterministic.reason}` : ""}`
957
+ : (deterministic?.reason ?? "Current deterministic check failed; this rule does not allow attestation."),
828
958
  finding,
829
- recommendation: finding?.recommendation,
959
+ recommendation: finding?.recommendation ?? deterministic?.recommendation,
830
960
  rationale: rule.rationale,
831
961
  current_rule: ruleSummary(rule),
832
962
  ...(failStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
@@ -857,6 +987,29 @@ export async function checkCompliance(repoPath, options = {}) {
857
987
  });
858
988
  continue;
859
989
  }
990
+ if (attestation.file_scoped === true && isFileScopableRule(rule)) {
991
+ const coveredFiles = new Set((attestation.source_fingerprints ?? []).map((fp) => fp.path));
992
+ const uncovered = await ruleUncoveredViolators(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll, coveredFiles);
993
+ if (uncovered.length > 0) {
994
+ const scopeStatus = rule.advisory ? "advisory" : rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail";
995
+ const uncoveredPaths = uniqueStrings(uncovered.map((f) => f.file).filter((f) => !!f));
996
+ results.push({
997
+ ...checkRuleIdentity(rule.id),
998
+ title: rule.title,
999
+ severity: rule.severity,
1000
+ status: scopeStatus,
1001
+ reason: rule.advisory
1002
+ ? "Advisory: informational only — does not affect compliance status."
1003
+ : `Attestation covers ${[...coveredFiles].join(", ")}; the rule also fires in ${uncoveredPaths.join(", ")}, which this attestation does not cover. Review the new violation(s) and re-attest (or fix them).`,
1004
+ finding: uncovered[0],
1005
+ recommendation: uncovered[0].recommendation,
1006
+ rationale: rule.rationale,
1007
+ current_rule: ruleSummary(rule),
1008
+ ...(scopeStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
1009
+ });
1010
+ continue;
1011
+ }
1012
+ }
860
1013
  const deprecated = grandfathered && (rule.deprecated_versions ?? []).includes(attestation.rule_version);
861
1014
  results.push({
862
1015
  ...checkRuleIdentity(rule.id),
@@ -886,6 +1039,9 @@ export async function checkCompliance(repoPath, options = {}) {
886
1039
  else if (isInferential) {
887
1040
  fallbackReason = "Inferential check required. Please provide a host-agent attestation.";
888
1041
  }
1042
+ else if (deterministic?.reason) {
1043
+ fallbackReason = deterministic.reason;
1044
+ }
889
1045
  else if (rule.enforcement.attestation.allowed) {
890
1046
  fallbackReason = "Deterministic check failed and no valid attestation exists.";
891
1047
  }
@@ -896,7 +1052,7 @@ export async function checkCompliance(repoPath, options = {}) {
896
1052
  status: baseStatus,
897
1053
  reason: fallbackReason,
898
1054
  finding,
899
- recommendation: finding?.recommendation,
1055
+ recommendation: finding?.recommendation ?? deterministic?.recommendation,
900
1056
  current_rule: ruleSummary(rule),
901
1057
  ...(isInferential && { inferential_prompt: rule.enforcement.inferential?.prompt }),
902
1058
  ...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
@@ -921,16 +1077,20 @@ export async function checkCompliance(repoPath, options = {}) {
921
1077
  }
922
1078
  const summary = summarize(results);
923
1079
  const deterministicPasses = results.filter((result) => result.status === "deterministic-pass");
1080
+ const runtimeSmokePasses = deterministicPasses.filter((result) => result.pass_basis === "runtime-smoke").length;
1081
+ const undetectedPlatformPasses = deterministicPasses.filter((result) => result.pass_basis === "undetected-platform").length;
1082
+ const absencePasses = deterministicPasses.length - runtimeSmokePasses - undetectedPlatformPasses;
924
1083
  const evidenceBasis = deterministicPasses.length > 0
925
1084
  ? {
926
- absent: deterministicPasses.filter((result) => result.pass_basis !== "undetected-platform").length,
927
- undetected_platform: deterministicPasses.filter((result) => result.pass_basis === "undetected-platform").length,
1085
+ absent: absencePasses,
1086
+ undetected_platform: undetectedPlatformPasses,
1087
+ ...(runtimeSmokePasses > 0 ? { runtime_smoke: runtimeSmokePasses } : {}),
928
1088
  }
929
1089
  : undefined;
930
1090
  const evidenceBasisNote = evidenceBasis
931
- ? `${deterministicPasses.length} of the passing rules passed by ABSENCE (the sensor found no violation, which is not positive proof the feature was built).${evidenceBasis.undetected_platform > 0
932
- ? ` ${evidenceBasis.undetected_platform} of those are vacuous — the rule's platform was not detected in this project, so no sensor ran.`
933
- : ""} Treat these as "no problem found", not "built and validated".`
1091
+ ? `${absencePasses + undetectedPlatformPasses} of the passing rules passed by ABSENCE (the sensor found no violation, which is not positive proof the feature was built).${undetectedPlatformPasses > 0
1092
+ ? ` ${undetectedPlatformPasses} of those are vacuous — the rule's platform was not detected in this project, so no sensor ran.`
1093
+ : ""}${runtimeSmokePasses > 0 ? ` ${runtimeSmokePasses} deterministic-pass rule(s) cleared with runtime-smoke evidence from a mounted app.` : ""} Treat absence-based passes as "no problem found", not "built and validated".`
934
1094
  : undefined;
935
1095
  const sourceCompleteness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
936
1096
  const blockProvided = sourceCompleteness && sourceCompleteness.missing.length > 0
@@ -997,14 +1157,18 @@ export async function checkCompliance(repoPath, options = {}) {
997
1157
  });
998
1158
  const uxHarness = await readUxHarness(repoRoot);
999
1159
  const uxAssessment = await assessUxHarness(inspection.effectiveRoot, uxHarness, compliance.outcome);
1000
- const experienceReport = buildExperienceReport({
1001
- checkStatus: status,
1002
- exitCode,
1003
- summary,
1004
- designContract: compliance.design_contract,
1005
- uxAssessment,
1006
- uxHarness,
1007
- });
1160
+ const openReviewItems = await experienceOpenReviewItems(repoRoot, findings);
1161
+ const experienceReport = {
1162
+ ...buildExperienceReport({
1163
+ checkStatus: status,
1164
+ exitCode,
1165
+ summary,
1166
+ designContract: compliance.design_contract,
1167
+ uxAssessment,
1168
+ uxHarness,
1169
+ }),
1170
+ openReviewItems,
1171
+ };
1008
1172
  return {
1009
1173
  status,
1010
1174
  exitCode,
@@ -1019,6 +1183,7 @@ export async function checkCompliance(repoPath, options = {}) {
1019
1183
  ...(baselineReport ? { baseline: baselineReport } : {}),
1020
1184
  uxHarness: uxAssessment,
1021
1185
  experienceReport,
1186
+ ...(attestationHygiene ? { attestation_hygiene: attestationHygiene } : {}),
1022
1187
  };
1023
1188
  }
1024
1189
  export async function generateExperienceReport(repoPath, options = {}) {
@@ -1086,6 +1251,92 @@ function sensorReviewGaps(raw) {
1086
1251
  }
1087
1252
  return gaps.slice(0, 50);
1088
1253
  }
1254
+ async function experienceOpenReviewItems(repoRoot, findings) {
1255
+ const intake = await readIntakeReviewItems(repoRoot);
1256
+ const validateFindings = validateFindingReviewItems(findings);
1257
+ const needsReview = intake.unresolvedNonblockingIntake.length > 0 ||
1258
+ intake.acknowledgedBlockingIntake.length > 0 ||
1259
+ validateFindings.length > 0;
1260
+ return {
1261
+ status: needsReview ? "needs-review" : "clear",
1262
+ unresolvedNonblockingIntake: intake.unresolvedNonblockingIntake,
1263
+ acknowledgedBlockingIntake: intake.acknowledgedBlockingIntake,
1264
+ validateFindings,
1265
+ nextStep: needsReview
1266
+ ? "Surface these items in the final handoff. Answer or explicitly scope unresolved intake, and treat validate findings as remaining review work even when vise check is green."
1267
+ : "No unresolved intake or validate findings were observed in the current report inputs.",
1268
+ };
1269
+ }
1270
+ async function readIntakeReviewItems(repoRoot) {
1271
+ const sidecar = await readJsonIfExists(path.join(sidecarDir(repoRoot), "intake.json"));
1272
+ if (!sidecar) {
1273
+ return { unresolvedNonblockingIntake: [], acknowledgedBlockingIntake: [] };
1274
+ }
1275
+ const answers = sidecar.answers && typeof sidecar.answers === "object" && !Array.isArray(sidecar.answers)
1276
+ ? sidecar.answers
1277
+ : {};
1278
+ const questions = Array.isArray(sidecar.questions) ? sidecar.questions : [];
1279
+ const unresolved = questions
1280
+ .map(intakeQuestionReviewItem)
1281
+ .filter((item) => Boolean(item))
1282
+ .filter((item) => !hasNonEmptyAnswer(answers, item.id));
1283
+ const acknowledgedBlocking = sidecar.acknowledged_unresolved_blocking === true
1284
+ ? unresolved.filter((item) => item.blocking)
1285
+ : [];
1286
+ return {
1287
+ unresolvedNonblockingIntake: unresolved.filter((item) => !item.blocking).map(stripBlockingFlag),
1288
+ acknowledgedBlockingIntake: acknowledgedBlocking.map(stripBlockingFlag),
1289
+ };
1290
+ }
1291
+ function intakeQuestionReviewItem(raw) {
1292
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1293
+ return undefined;
1294
+ }
1295
+ const question = raw;
1296
+ if (typeof question.id !== "string" || typeof question.question !== "string") {
1297
+ return undefined;
1298
+ }
1299
+ return {
1300
+ id: question.id,
1301
+ question: question.question,
1302
+ ...(typeof question.why === "string" ? { why: question.why } : {}),
1303
+ ...(Array.isArray(question.options) ? { options: question.options.filter((option) => typeof option === "string") } : {}),
1304
+ blocking: question.blocksImplementationWhenMissing === true,
1305
+ };
1306
+ }
1307
+ function stripBlockingFlag(item) {
1308
+ const { blocking: _blocking, ...rest } = item;
1309
+ return rest;
1310
+ }
1311
+ function hasNonEmptyAnswer(answers, id) {
1312
+ return Object.prototype.hasOwnProperty.call(answers, id) && String(answers[id] ?? "").trim() !== "";
1313
+ }
1314
+ function validateFindingReviewItems(findings) {
1315
+ const items = [];
1316
+ const seen = new Set();
1317
+ for (const finding of findings) {
1318
+ if (finding.severity !== "warning" && finding.severity !== "error") {
1319
+ continue;
1320
+ }
1321
+ const key = `${finding.ruleId}|${finding.sensorId ?? ""}|${finding.file ?? ""}|${finding.message}`;
1322
+ if (seen.has(key)) {
1323
+ continue;
1324
+ }
1325
+ seen.add(key);
1326
+ items.push({
1327
+ ruleId: finding.ruleId,
1328
+ ...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
1329
+ severity: finding.severity,
1330
+ message: finding.message,
1331
+ ...(finding.file ? { file: finding.file } : {}),
1332
+ ...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
1333
+ });
1334
+ if (items.length >= 50) {
1335
+ break;
1336
+ }
1337
+ }
1338
+ return items;
1339
+ }
1089
1340
  function fallbackExperienceReport(check) {
1090
1341
  const uxAssessment = check.uxHarness ?? {
1091
1342
  status: "absent",
@@ -1136,10 +1387,12 @@ export async function syncCompliance(repoPath) {
1136
1387
  continue;
1137
1388
  }
1138
1389
  const passBasis = result.pass_basis ?? "absent";
1139
- const confidence = passBasis === "undetected-platform" ? "low" : "medium";
1140
- const rationale = passBasis === "undetected-platform"
1141
- ? "Deterministic check passed by absence, but this rule targets a platform not detected in this project, so no sensor exercised it — treat as not-yet-validated."
1142
- : "Deterministic check passed: the sensor ran and found no violation. Absence of a finding is not positive proof the feature was built.";
1390
+ const confidence = passBasis === "runtime-smoke" ? "high" : passBasis === "undetected-platform" ? "low" : "medium";
1391
+ const rationale = passBasis === "runtime-smoke"
1392
+ ? "Deterministic check passed: runtime mount-smoke evidence shows the declared collection surface(s) resolved in the running app."
1393
+ : passBasis === "undetected-platform"
1394
+ ? "Deterministic check passed by absence, but this rule targets a platform not detected in this project, so no sensor exercised it — treat as not-yet-validated."
1395
+ : "Deterministic check passed: the sensor ran and found no violation. Absence of a finding is not positive proof the feature was built.";
1143
1396
  const attestation = buildAttestation(compliance, rule, "spf-deterministic", confidence, undefined, rationale, { ...(result.evidence ?? {}), pass_basis: passBasis });
1144
1397
  await writeJson(filePath, attestation);
1145
1398
  written.push(filePath);
@@ -1159,6 +1412,7 @@ export async function syncCompliance(repoPath) {
1159
1412
  written,
1160
1413
  removed,
1161
1414
  last_synced_at: compliance.last_synced_at,
1415
+ ...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
1162
1416
  };
1163
1417
  }
1164
1418
  export async function attestRule(args) {
@@ -1202,10 +1456,16 @@ export async function attestRule(args) {
1202
1456
  throw new Error(`Rule does not allow local human attestations: ${args.ruleId}`);
1203
1457
  }
1204
1458
  validateEvidence(rule, args.evidence);
1205
- const sensorWarning = await liveSensorContradiction(repoRoot, compliance, rule);
1459
+ const { warning: sensorWarning, violatorFiles } = await liveSensorContradiction(repoRoot, compliance, rule);
1206
1460
  await mkdir(attestationsDir(repoRoot), { recursive: true });
1207
- const sourceFingerprints = await collectSourceFingerprints(repoRoot, sourceRootForCompliance(repoRoot, compliance), args.evidence);
1208
- const attestation = buildAttestation(compliance, rule, args.signer, args.confidence, args.identity, args.rationale, args.evidence, sourceFingerprints);
1461
+ const sourceRoot = sourceRootForCompliance(repoRoot, compliance);
1462
+ const evidenceFingerprints = await collectSourceFingerprints(repoRoot, sourceRoot, args.evidence);
1463
+ const fileScoped = isFileScopableRule(rule);
1464
+ const violatorFingerprints = fileScoped && violatorFiles.length > 0
1465
+ ? await collectViolatorFingerprints(repoRoot, sourceRoot, violatorFiles, evidenceFingerprints)
1466
+ : [];
1467
+ const sourceFingerprints = [...evidenceFingerprints, ...violatorFingerprints];
1468
+ const attestation = buildAttestation(compliance, rule, args.signer, args.confidence, args.identity, args.rationale, args.evidence, sourceFingerprints, fileScoped);
1209
1469
  const filePath = path.join(attestationsDir(repoRoot), attestationPathFor(contractRuleId));
1210
1470
  await writeJson(filePath, attestation);
1211
1471
  return {
@@ -1214,6 +1474,7 @@ export async function attestRule(args) {
1214
1474
  destination: filePath,
1215
1475
  signer_claim: attestation.signer_claim,
1216
1476
  source_fingerprints: sourceFingerprints,
1477
+ ...(fileScoped ? { file_scoped: true, scoped_files: [...new Set(sourceFingerprints.map((fp) => fp.path))] } : {}),
1217
1478
  ...(sensorWarning ? { sensor_warning: sensorWarning } : {}),
1218
1479
  };
1219
1480
  }
@@ -1321,6 +1582,7 @@ export async function statusCompliance(repoPath, options = {}) {
1321
1582
  ...(check.evidence_basis_note ? { evidence_basis_note: check.evidence_basis_note } : {}),
1322
1583
  ...(check.completeness ? { completeness: check.completeness } : {}),
1323
1584
  ...(check.selectedOptionalCapabilities ? { selectedOptionalCapabilities: check.selectedOptionalCapabilities } : {}),
1585
+ ...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
1324
1586
  ...(compliance?.design_contract && { design_contract: compliance.design_contract }),
1325
1587
  ...(engagement && {
1326
1588
  engagement: {
@@ -1490,6 +1752,187 @@ function contractDrift(compliance, rules) {
1490
1752
  }
1491
1753
  return results;
1492
1754
  }
1755
+ async function assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot) {
1756
+ const checks = rule.enforcement.deterministic ?? [];
1757
+ const passedEvidence = [];
1758
+ let passBasis = "absent";
1759
+ for (const check of checks) {
1760
+ if (check.check === "validator-finding-absent") {
1761
+ const finding = findingsById.get(check.finding_rule_id);
1762
+ if (finding) {
1763
+ return {
1764
+ passed: false,
1765
+ finding,
1766
+ reason: "Current deterministic validator check failed.",
1767
+ recommendation: finding.recommendation,
1768
+ };
1769
+ }
1770
+ passedEvidence.push({ source: "validate_setup", finding_absent: [check.finding_rule_id] });
1771
+ continue;
1772
+ }
1773
+ if (check.check === "runtime-smoke-evidence-passed") {
1774
+ const smoke = await assessRuntimeSmokeEvidence(repoRoot, check);
1775
+ if (!smoke.passed) {
1776
+ return smoke;
1777
+ }
1778
+ if (smoke.evidence) {
1779
+ passedEvidence.push(smoke.evidence);
1780
+ }
1781
+ passBasis = "runtime-smoke";
1782
+ continue;
1783
+ }
1784
+ const unknown = check;
1785
+ return {
1786
+ passed: false,
1787
+ reason: `Unsupported deterministic check kind: ${String(unknown.check ?? "(missing)")}.`,
1788
+ };
1789
+ }
1790
+ if (passBasis !== "runtime-smoke") {
1791
+ const identity = checkRuleIdentity(rule.id);
1792
+ const rulePlatform = identity.validator?.platform;
1793
+ passBasis =
1794
+ rulePlatform && !platforms.some((p) => canonicalPlatform(p) === canonicalPlatform(rulePlatform))
1795
+ ? "undetected-platform"
1796
+ : "absent";
1797
+ }
1798
+ const validatorAbsent = deterministicFindingIds(rule);
1799
+ const runtimeSmoke = passedEvidence.find((item) => item.source === "runtime_smoke");
1800
+ let evidence;
1801
+ if (runtimeSmoke && validatorAbsent.length > 0) {
1802
+ evidence = {
1803
+ source: "deterministic_checks",
1804
+ validate_setup: { finding_absent: validatorAbsent },
1805
+ runtime_smoke: runtimeSmoke,
1806
+ };
1807
+ }
1808
+ else if (runtimeSmoke) {
1809
+ evidence = runtimeSmoke;
1810
+ }
1811
+ else {
1812
+ evidence = { source: "validate_setup", finding_absent: validatorAbsent };
1813
+ }
1814
+ return {
1815
+ passed: true,
1816
+ pass_basis: passBasis,
1817
+ evidence,
1818
+ };
1819
+ }
1820
+ async function assessRuntimeSmokeEvidence(repoRoot, check) {
1821
+ const relativePath = check.path ?? "sp-vise/evidence/runtime-smoke.json";
1822
+ const evidencePath = path.resolve(repoRoot, relativePath);
1823
+ const displayPath = path.relative(repoRoot, evidencePath).split(path.sep).join(path.posix.sep);
1824
+ const recommendation = runtimeSmokeCaptureRecommendationText();
1825
+ if (!pathInside(repoRoot, evidencePath)) {
1826
+ return {
1827
+ passed: false,
1828
+ reason: `Runtime smoke evidence path must stay inside the repository: ${relativePath}`,
1829
+ recommendation,
1830
+ };
1831
+ }
1832
+ let parsed;
1833
+ try {
1834
+ parsed = JSON.parse(await readFile(evidencePath, "utf8"));
1835
+ }
1836
+ catch {
1837
+ return {
1838
+ passed: false,
1839
+ reason: `Missing or unreadable runtime smoke evidence at ${displayPath}.`,
1840
+ recommendation,
1841
+ };
1842
+ }
1843
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
1844
+ const surfaceResults = results.filter(isRuntimeSmokeSurfaceResult);
1845
+ const failing = surfaceResults.filter((result) => result.verdict !== "pass");
1846
+ const evidence = {
1847
+ source: "runtime_smoke",
1848
+ file: displayPath,
1849
+ passed: parsed.passed === true,
1850
+ ...(typeof parsed.platform === "string" ? { platform: parsed.platform } : {}),
1851
+ results: surfaceResults,
1852
+ };
1853
+ if (parsed.passed !== true) {
1854
+ const failedSurfaces = failing.map((result) => `${result.surface}:${result.state}`).join(", ");
1855
+ return {
1856
+ passed: false,
1857
+ evidence,
1858
+ reason: failedSurfaces
1859
+ ? `Runtime smoke evidence at ${displayPath} did not pass (${failedSurfaces}).`
1860
+ : `Runtime smoke evidence at ${displayPath} did not pass.`,
1861
+ recommendation,
1862
+ };
1863
+ }
1864
+ if (surfaceResults.length === 0) {
1865
+ return {
1866
+ passed: false,
1867
+ evidence,
1868
+ reason: `Runtime smoke evidence at ${displayPath} has passed=true but no surface results; rerun \`vise smoke\` so the gate can verify a mounted collection surface.`,
1869
+ recommendation,
1870
+ };
1871
+ }
1872
+ if (failing.length > 0) {
1873
+ return {
1874
+ passed: false,
1875
+ evidence,
1876
+ reason: `Runtime smoke evidence at ${displayPath} has passed=true but contains failing surface results (${failing.map((result) => result.surface).join(", ")}).`,
1877
+ recommendation,
1878
+ };
1879
+ }
1880
+ const smokeConfig = await readJsonIfExists(path.join(sidecarDir(repoRoot), "smoke.json"));
1881
+ const expectedSurfaceIds = Array.isArray(smokeConfig?.surfaces)
1882
+ ? smokeConfig.surfaces.map((surface) => surface.id).filter((id) => typeof id === "string" && id.length > 0)
1883
+ : [];
1884
+ if (expectedSurfaceIds.length > 0) {
1885
+ const passedSurfaceIds = new Set(surfaceResults.filter((result) => result.verdict === "pass").map((result) => result.surface));
1886
+ const missing = expectedSurfaceIds.filter((id) => !passedSurfaceIds.has(id));
1887
+ if (missing.length > 0) {
1888
+ return {
1889
+ passed: false,
1890
+ evidence,
1891
+ reason: `Runtime smoke evidence at ${displayPath} does not include passing results for declared collection surface(s): ${missing.join(", ")}.`,
1892
+ recommendation,
1893
+ };
1894
+ }
1895
+ const expectedSurfacesById = new Map(smokeConfig?.surfaces
1896
+ ?.filter((surface) => typeof surface.id === "string" && surface.id.length > 0)
1897
+ .map((surface) => [surface.id, surface]) ?? []);
1898
+ const underpopulated = surfaceResults.filter((result) => {
1899
+ const expected = expectedSurfacesById.get(result.surface);
1900
+ return expected?.expect === "populated" && result.verdict === "pass" && !runtimeSmokeStateHasPositiveCount(result.state);
1901
+ });
1902
+ if (underpopulated.length > 0) {
1903
+ return {
1904
+ passed: false,
1905
+ evidence,
1906
+ reason: `Runtime smoke evidence at ${displayPath} has passing populated surface result(s) without count > 0: ${underpopulated.map((result) => `${result.surface}:${result.state}`).join(", ")}.`,
1907
+ recommendation,
1908
+ };
1909
+ }
1910
+ }
1911
+ return {
1912
+ passed: true,
1913
+ pass_basis: "runtime-smoke",
1914
+ evidence,
1915
+ };
1916
+ }
1917
+ function runtimeSmokeCaptureRecommendationText() {
1918
+ return [
1919
+ "Declare collection surfaces in `sp-vise/smoke.json` and emit `VISE_SMOKE surface=<id> state=loading|empty|loaded count=N|error:<message>` from the mounted app's real SDK query lifecycle; `expect: \"populated\"` requires loaded count=N with N > 0.",
1920
+ "Cold-launch into the social route, wait for the visible collection to resolve, capture only the runtime log, run `vise smoke . --log <capture>`, then rerun `vise check`.",
1921
+ "Android: `adb logcat -c` before launch, then `adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log`.",
1922
+ "iOS: after `simctl launch`, run `xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log`.",
1923
+ "Web/React Native/Flutter: capture console/debugPrint VISE_SMOKE lines from the real mounted app, not a standalone SDK probe.",
1924
+ ].join(" ");
1925
+ }
1926
+ function isRuntimeSmokeSurfaceResult(value) {
1927
+ if (!value || typeof value !== "object")
1928
+ return false;
1929
+ const item = value;
1930
+ return typeof item.surface === "string" && typeof item.state === "string" && typeof item.verdict === "string";
1931
+ }
1932
+ function runtimeSmokeStateHasPositiveCount(state) {
1933
+ const match = state.match(/^loaded\b[\s\S]*?\bcount\s*=\s*(\d+)/i);
1934
+ return Boolean(match && Number(match[1]) > 0);
1935
+ }
1493
1936
  function deterministicFinding(rule, findingsById) {
1494
1937
  for (const id of deterministicFindingIds(rule)) {
1495
1938
  const finding = findingsById.get(id);
@@ -1501,32 +1944,39 @@ function deterministicFinding(rule, findingsById) {
1501
1944
  }
1502
1945
  async function liveSensorContradiction(repoRoot, compliance, rule) {
1503
1946
  if (deterministicFindingIds(rule).length === 0) {
1504
- return undefined;
1947
+ return { violatorFiles: [] };
1505
1948
  }
1506
1949
  const inspection = await inspectProject(repoRoot, compliance.surface?.path === "." ? undefined : compliance.surface?.path);
1507
1950
  const platforms = Array.from(new Set([...inspection.platforms, ...(compliance.surface?.platforms ?? [])]));
1508
1951
  if (platforms.length === 0) {
1509
- return undefined;
1952
+ return { violatorFiles: [] };
1510
1953
  }
1511
1954
  const allFindings = await Promise.all(platforms.map((platform) => validateSetup(inspection.effectiveRoot, platform)));
1512
1955
  const findingsById = new Map();
1956
+ const findingsByIdAll = new Map();
1513
1957
  for (const finding of allFindings.flat()) {
1514
1958
  findingsById.set(finding.ruleId, finding);
1959
+ appendFinding(findingsByIdAll, finding.ruleId, finding);
1515
1960
  if (finding.sensorId) {
1516
1961
  findingsById.set(finding.sensorId, finding);
1962
+ appendFinding(findingsByIdAll, finding.sensorId, finding);
1517
1963
  }
1518
1964
  }
1519
1965
  const finding = deterministicFinding(rule, findingsById);
1520
1966
  if (!finding) {
1521
- return undefined;
1967
+ return { violatorFiles: [] };
1522
1968
  }
1969
+ const violatorFiles = await ruleViolatorFiles(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll);
1523
1970
  return {
1524
- message: "The live deterministic sensor STILL reports a violation for this rule. This attestation records your override; verify the source actually satisfies the rule before relying on it.",
1525
- sensor_finding: {
1526
- ruleId: finding.ruleId,
1527
- ...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
1528
- ...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
1971
+ warning: {
1972
+ message: "The live deterministic sensor STILL reports a violation for this rule. This attestation records your override; verify the source actually satisfies the rule before relying on it.",
1973
+ sensor_finding: {
1974
+ ruleId: finding.ruleId,
1975
+ ...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
1976
+ ...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
1977
+ },
1529
1978
  },
1979
+ violatorFiles,
1530
1980
  };
1531
1981
  }
1532
1982
  function deterministicFindingIds(rule) {
@@ -1534,6 +1984,117 @@ function deterministicFindingIds(rule) {
1534
1984
  .filter((check) => check.check === "validator-finding-absent")
1535
1985
  .map((check) => check.finding_rule_id);
1536
1986
  }
1987
+ function appendFinding(map, key, finding) {
1988
+ const existing = map.get(key);
1989
+ if (existing) {
1990
+ existing.push(finding);
1991
+ }
1992
+ else {
1993
+ map.set(key, [finding]);
1994
+ }
1995
+ }
1996
+ const LIVE_SENSOR_VIOLATOR_FIELD = "live_sensor_violator";
1997
+ export const FILE_SCOPABLE_SENSOR_SUFFIXES = new Set([
1998
+ "live-collection.api-mismatch",
1999
+ "story.live-collection",
2000
+ "search.live-collection",
2001
+ "notification-tray.live-collection",
2002
+ "event.live-collection",
2003
+ "blocked-users.live-collection",
2004
+ "invitation.live-collection",
2005
+ "membership.use-live-collection",
2006
+ "posts.status-filter-applied",
2007
+ "posts.activity-tag-filter",
2008
+ "posts.reaction-stale-post-ref",
2009
+ "poll.vote-status-guard",
2010
+ "pagination.cursor-opaque",
2011
+ "posts.parent-child-rendered",
2012
+ "comment.reference-type-enum",
2013
+ "channel.type-matches-shape",
2014
+ "reactions.configured-name-used",
2015
+ "image-post.child-resolution-awaited",
2016
+ "file-upload.via-amity-file-client",
2017
+ "user.ban-state-respected",
2018
+ "flag-count.not-leaked-to-non-mods",
2019
+ "moderation.role-gated-action",
2020
+ "custom-post-type.dataType-declared",
2021
+ "feed.target-type-explicit",
2022
+ "feed.post-datatype-handled",
2023
+ "feed.ui-states-present",
2024
+ "comments.target-resolved",
2025
+ "chat.channel-type-dm",
2026
+ "chat.deprecated-marker-sync",
2027
+ "chat.deprecated-subchannel",
2028
+ "livestream.deprecated-stream",
2029
+ "follow.status-subscription",
2030
+ "session-handler.retained",
2031
+ "client.no-ssr-init",
2032
+ "community.avatar-from-sdk",
2033
+ "feed.poll-answer-data-shape",
2034
+ "comments.query-has-limit",
2035
+ "comments.thread-ui-states-present",
2036
+ "chat.sort-explicit",
2037
+ "profile.social-counts-from-sdk",
2038
+ "community.display-name-from-sdk",
2039
+ "feed.room-post-fetched",
2040
+ "logging.no-secret-in-log",
2041
+ "logging.no-pii-in-log",
2042
+ "auth.no-literal-user-id",
2043
+ "feed.target.literal",
2044
+ ]);
2045
+ function isFileScopableRule(rule) {
2046
+ return deterministicFindingIds(rule).some((id) => [...FILE_SCOPABLE_SENSOR_SUFFIXES].some((suffix) => id === suffix || id.endsWith(`.${suffix}`)));
2047
+ }
2048
+ async function ruleViolatorFiles(repoRoot, sourceRoot, rule, findingsByIdAll) {
2049
+ const files = new Set();
2050
+ for (const id of deterministicFindingIds(rule)) {
2051
+ for (const finding of findingsByIdAll.get(id) ?? []) {
2052
+ if (!finding.file)
2053
+ continue;
2054
+ const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, finding.file);
2055
+ files.add(resolved ? resolved.relativePath : finding.file);
2056
+ }
2057
+ }
2058
+ return [...files];
2059
+ }
2060
+ async function ruleUncoveredViolators(repoRoot, sourceRoot, rule, findingsByIdAll, coveredFiles) {
2061
+ const uncovered = [];
2062
+ const seen = new Set();
2063
+ for (const id of deterministicFindingIds(rule)) {
2064
+ for (const finding of findingsByIdAll.get(id) ?? []) {
2065
+ if (!finding.file)
2066
+ continue;
2067
+ const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, finding.file);
2068
+ const normalized = resolved ? resolved.relativePath : finding.file;
2069
+ if (coveredFiles.has(normalized) || seen.has(normalized))
2070
+ continue;
2071
+ seen.add(normalized);
2072
+ uncovered.push({ ...finding, file: normalized });
2073
+ }
2074
+ }
2075
+ return uncovered;
2076
+ }
2077
+ async function collectViolatorFingerprints(repoRoot, sourceRoot, violatorFiles, evidenceFingerprints) {
2078
+ const capturedAt = new Date().toISOString();
2079
+ const alreadyFingerprinted = new Set(evidenceFingerprints.map((fp) => fp.path));
2080
+ const fingerprints = [];
2081
+ for (const file of violatorFiles) {
2082
+ const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, file);
2083
+ if (!resolved || alreadyFingerprinted.has(resolved.relativePath)) {
2084
+ continue;
2085
+ }
2086
+ alreadyFingerprinted.add(resolved.relativePath);
2087
+ const fileStat = await stat(resolved.absolutePath);
2088
+ fingerprints.push({
2089
+ evidence_field: LIVE_SENSOR_VIOLATOR_FIELD,
2090
+ path: resolved.relativePath,
2091
+ sha256: await digestFile(resolved.absolutePath),
2092
+ size_bytes: fileStat.size,
2093
+ captured_at: capturedAt,
2094
+ });
2095
+ }
2096
+ return fingerprints;
2097
+ }
1537
2098
  function uniqueStrings(values) {
1538
2099
  const seen = new Set();
1539
2100
  const result = [];
@@ -1546,7 +2107,7 @@ function uniqueStrings(values) {
1546
2107
  }
1547
2108
  return result;
1548
2109
  }
1549
- function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = []) {
2110
+ function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = [], fileScoped = false) {
1550
2111
  const ref = compliance.rules.find((item) => item.rule_id === rule.id);
1551
2112
  if (!ref) {
1552
2113
  throw new Error(`Rule is not in compliance contract: ${rule.id}`);
@@ -1568,6 +2129,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
1568
2129
  signature: null,
1569
2130
  evidence,
1570
2131
  source_fingerprints: sourceFingerprints,
2132
+ ...(fileScoped ? { file_scoped: true } : {}),
1571
2133
  rationale_for_attestation: rationale,
1572
2134
  attested_at: new Date().toISOString(),
1573
2135
  };
@@ -1716,13 +2278,19 @@ export function ruleSummary(rule) {
1716
2278
  pattern: "pattern" in blocker ? blocker.pattern : undefined,
1717
2279
  reason: blocker.reason,
1718
2280
  })),
1719
- deterministic_check_ids: (rule.enforcement.deterministic ?? []).map((check) => check.finding_rule_id),
2281
+ deterministic_check_ids: (rule.enforcement.deterministic ?? []).map(deterministicCheckId),
1720
2282
  attestation_allowed: rule.enforcement.attestation.allowed,
1721
2283
  host_agent_min_confidence: rule.enforcement.attestation.host_agent_min_confidence,
1722
2284
  human_allowed: rule.enforcement.attestation.human_allowed !== false,
1723
2285
  evidence_field_names: (rule.enforcement.attestation.evidence_required ?? []).map((field) => field.field),
1724
2286
  };
1725
2287
  }
2288
+ function deterministicCheckId(check) {
2289
+ if (check.check === "validator-finding-absent") {
2290
+ return check.finding_rule_id;
2291
+ }
2292
+ return `${check.check}:${check.path ?? "sp-vise/evidence/runtime-smoke.json"}`;
2293
+ }
1726
2294
  export async function runBlockers(rule, root) {
1727
2295
  const fired = [];
1728
2296
  for (const blocker of rule.enforcement.blockers ?? []) {
@@ -1805,16 +2373,78 @@ async function readAttestations(repoRoot) {
1805
2373
  if (!entry.endsWith(".json")) {
1806
2374
  continue;
1807
2375
  }
1808
- const attestation = await readJsonIfExists(path.join(dir, entry));
1809
- if (attestation?.rule_id && attestation.payload_hash) {
1810
- const { payload_hash, ...withoutHash } = attestation;
1811
- if (digestJson(withoutHash) === payload_hash) {
1812
- result.set(attestation.rule_id, attestation);
1813
- }
2376
+ const candidate = await readAttestationCandidate(path.join(dir, entry));
2377
+ if (candidate.status === "valid") {
2378
+ result.set(candidate.attestation.rule_id, candidate.attestation);
1814
2379
  }
1815
2380
  }
1816
2381
  return result;
1817
2382
  }
2383
+ async function readAttestationHygiene(repoRoot, compliance) {
2384
+ const dir = attestationsDir(repoRoot);
2385
+ const activeRuleIds = new Set(compliance.rules.map((rule) => rule.rule_id));
2386
+ const ignored = [];
2387
+ const invalid = [];
2388
+ let entries = [];
2389
+ try {
2390
+ entries = await readdir(dir);
2391
+ }
2392
+ catch {
2393
+ return undefined;
2394
+ }
2395
+ for (const entry of entries) {
2396
+ if (!entry.endsWith(".json")) {
2397
+ continue;
2398
+ }
2399
+ const rel = path.posix.join(complianceDirName, attestationsDirName, entry);
2400
+ const candidate = await readAttestationCandidate(path.join(dir, entry));
2401
+ if (candidate.status === "invalid") {
2402
+ invalid.push({ file: rel, ...(candidate.rule_id ? { rule_id: candidate.rule_id } : {}), reason: candidate.reason });
2403
+ continue;
2404
+ }
2405
+ if (!activeRuleIds.has(candidate.attestation.rule_id)) {
2406
+ ignored.push({
2407
+ file: rel,
2408
+ rule_id: candidate.attestation.rule_id,
2409
+ reason: "Attestation rule_id is not in the current compliance contract. It may be stale, off-platform, or from a previous sidecar scope; it is ignored by vise check.",
2410
+ });
2411
+ }
2412
+ }
2413
+ if (ignored.length === 0 && invalid.length === 0) {
2414
+ return undefined;
2415
+ }
2416
+ return {
2417
+ status: "needs-review",
2418
+ ignored,
2419
+ invalid,
2420
+ nextStep: "Review ignored/invalid attestation files before using the evidence trail for handoff. Remove stale files only when you are sure they are unrelated, or re-run vise init/check/sync for the intended scope.",
2421
+ };
2422
+ }
2423
+ async function readAttestationCandidate(filePath) {
2424
+ let parsed;
2425
+ try {
2426
+ parsed = JSON.parse(await readFile(filePath, "utf8"));
2427
+ }
2428
+ catch {
2429
+ return { status: "invalid", reason: "Attestation file is not valid JSON." };
2430
+ }
2431
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2432
+ return { status: "invalid", reason: "Attestation file is not a JSON object." };
2433
+ }
2434
+ const attestation = parsed;
2435
+ const ruleId = typeof attestation.rule_id === "string" ? attestation.rule_id : undefined;
2436
+ if (!ruleId) {
2437
+ return { status: "invalid", reason: "Attestation file is missing rule_id." };
2438
+ }
2439
+ if (typeof attestation.payload_hash !== "string" || attestation.payload_hash.length === 0) {
2440
+ return { status: "invalid", rule_id: ruleId, reason: "Attestation file is missing payload_hash." };
2441
+ }
2442
+ const { payload_hash, ...withoutHash } = attestation;
2443
+ if (digestJson(withoutHash) !== payload_hash) {
2444
+ return { status: "invalid", rule_id: ruleId, reason: "Attestation payload_hash does not match the file contents." };
2445
+ }
2446
+ return { status: "valid", attestation };
2447
+ }
1818
2448
  async function readJsonIfExists(filePath) {
1819
2449
  try {
1820
2450
  if (!(await stat(filePath)).isFile()) {
@@ -1858,9 +2488,6 @@ function sidecarReadme(compliance) {
1858
2488
  "",
1859
2489
  ].join("\n");
1860
2490
  }
1861
- function sidecarDir(repoRoot) {
1862
- return path.join(repoRoot, complianceDirName);
1863
- }
1864
2491
  function attestationsDir(repoRoot) {
1865
2492
  return path.join(sidecarDir(repoRoot), attestationsDirName);
1866
2493
  }