@amityco/social-plus-vise 1.4.5 → 1.5.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.
@@ -4,7 +4,7 @@ import { BROAD_SOCIAL_REGEX, DESIGN_REGEX, getOutcomeDefinition, hasAnswer, plan
4
4
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
5
5
  import { availableOptionalCapabilityIds, capabilityChecklist, optionalCapabilityChecklist, platformCapabilityAvailability, selectedOptionalCapabilityIds, slimCapabilityAvailability, slimCapabilityItem, } from "../capabilities.js";
6
6
  import { applicableCompliancePlanRuleSummaries } from "./compliance.js";
7
- import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, readDesignProfile, buildDesignReviewLoop, } from "./design.js";
7
+ import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, readDesignProfile, readDesignProfileDiagnostic, buildDesignReviewLoop, } from "./design.js";
8
8
  import { sdkVersionGuidance } from "./sdkVersion.js";
9
9
  import { detectCommandSensors } from "./harness.js";
10
10
  import { inspectProject } from "./project.js";
@@ -65,8 +65,8 @@ function answersFromInput(raw) {
65
65
  }
66
66
  async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}) {
67
67
  const repoRoot = path.resolve(repoPath);
68
+ const flow = await readFlowState(repoRoot);
68
69
  if (!hasAnswer(answers, "solution_path")) {
69
- const flow = await readFlowState(repoRoot);
70
70
  const confirmedPath = flow?.request === request && flow.blueprint?.confirmed_digest ? flow.blueprint?.path ?? null : null;
71
71
  if (confirmedPath) {
72
72
  answers = { ...answers, solution_path: confirmedPath };
@@ -75,7 +75,7 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
75
75
  const inspection = await inspectProject(repoRoot, surfacePath);
76
76
  const root = inspection.effectiveRoot;
77
77
  const storedCreativeSelection = await readCreativeSelection(repoRoot);
78
- const creativeSelection = storedCreativeSelection && creativeSelectionAppliesToRequest(storedCreativeSelection, request)
78
+ const creativeSelection = storedCreativeSelection && creativeSelectionAppliesToRequest(storedCreativeSelection, request, flow)
79
79
  ? storedCreativeSelection
80
80
  : undefined;
81
81
  const creativeContext = creativeSelection ? creativeContextForPlan(creativeSelection) : undefined;
@@ -113,7 +113,10 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
113
113
  const designReview = designReviewGuidance(repoRoot, designContract, answers);
114
114
  const acceptedDesignContract = designReview.status === "accepted" ? designContract : null;
115
115
  const designProfile = acceptedDesignContract ? await readDesignProfile(repoRoot) : null;
116
- const designBrief = acceptedDesignContract ? buildDesignBrief(acceptedDesignContract, designProfile) : undefined;
116
+ const designProfileDiagnostic = acceptedDesignContract && !designProfile ? await readDesignProfileDiagnostic(repoRoot) : undefined;
117
+ const designBrief = acceptedDesignContract
118
+ ? buildDesignBrief(acceptedDesignContract, designProfile, designProfileDiagnostic?.reason)
119
+ : undefined;
117
120
  const designReviewLoop = designProfile
118
121
  ? solutionPath.recommendation === "uikit"
119
122
  ? {
@@ -179,6 +182,20 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
179
182
  uikitGuidedUnknown: platformDetected && outcome === "unknown" && Boolean(uikitCustomization),
180
183
  creativeSelected: Boolean(creativeSelection),
181
184
  }));
185
+ const experienceBundle = !creativeSelection && !socialWorkplan && outcome !== "unknown" ? experienceBundleFor(request) : undefined;
186
+ const experienceHint = experienceBundle
187
+ ? {
188
+ status: "consider-experience-expansion",
189
+ detectedExperience: experienceBundle.id,
190
+ variantId: experienceBundle.variantId,
191
+ companionSurfaces: experienceBundle.surfaces.map((surface) => surface.id),
192
+ routeTo: "vise creative",
193
+ message: `This request reads as a fuller "${experienceBundle.id}" experience (usually ${experienceBundle.surfaces
194
+ .map((surface) => surface.id)
195
+ .join(", ")}), but it classified to a single ${outcome} surface. ADVISORY: before building a lone surface, run ` +
196
+ `\`vise creative . --request "<intent>"\`, review the candidate experiences, and accept one if it fits — then \`vise workplan\` governs the multi-surface journey. Skipping is fine for a deliberately single-surface build; this never gates \`vise check\`.`,
197
+ }
198
+ : undefined;
182
199
  return {
183
200
  outcome,
184
201
  platform,
@@ -191,6 +208,7 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
191
208
  creativeContext,
192
209
  uxHarness: uxHarnessContext,
193
210
  socialWorkplan,
211
+ ...(experienceHint ? { experienceHint } : {}),
194
212
  capabilityAvailability: slimCapabilityAvailability(capabilityAvailability),
195
213
  designReview,
196
214
  decisionsRequired,
@@ -312,7 +330,7 @@ function crossPlatformImplementationSteps(platform, packageJsonText) {
312
330
  {
313
331
  step: "React Native target-discovery handoff: bridge only local API key/user/region config through ignored native build config; never bridge communityId, postId, channelId, targetId, or feedId. Resolve those from SDK search/list/create results or host navigation/app state, then pass the returned SDK object/id through normal React state. Treat native config bridge and native module output as unproven until a release/debug runtime smoke shows non-empty config, no `NativeModule: AsyncStorage is null` or `NativeModule.RNCNetInfo is null` log, and an established SDK session; a screen that says credentials/config are missing is failed populated proof even if BuildConfig and TypeScript checks pass. Record runtime proof in evidence/notes, not end-user UI: do not render `CLIENT`, `SESSION`, `RUNTIME`, SDK, env, region, setup/readiness-debug labels, visible runtime/probe/status panels, or hidden no-op controls in the product screen.",
314
332
  evidence: [
315
- "requiredInputs.concrete feed target",
333
+ "requiredInputs.feed source and post creation target",
316
334
  "runtimeReadiness.targetDiscovery",
317
335
  "runtimeReadiness.runtimeProof",
318
336
  ],
@@ -632,17 +650,32 @@ function uxHarnessImplementationRules(context) {
632
650
  ...context.implementationGuidance,
633
651
  ];
634
652
  }
635
- function creativeSelectionAppliesToRequest(selection, request) {
653
+ function creativeSelectionAppliesToRequest(selection, request, flow) {
636
654
  const normalizedRequest = normalizeCreativeRequest(request);
637
655
  const original = normalizeCreativeRequest(selection.source.request);
638
656
  const planRequest = normalizeCreativeRequest(selection.implementationContext.planRequest);
639
657
  if (!normalizedRequest || !original) {
640
658
  return false;
641
659
  }
660
+ const requestedOutcome = resolveOutcome(request, {});
661
+ if (omittedCreativeOutcomeSet(flow).has(requestedOutcome)) {
662
+ return false;
663
+ }
642
664
  return (normalizedRequest === original ||
643
665
  normalizedRequest === planRequest ||
644
666
  normalizedRequest.includes(original) ||
645
- original.includes(normalizedRequest));
667
+ original.includes(normalizedRequest) ||
668
+ (Boolean(selection.selectedVariant?.id) && creativeSelectionOutcomeSet(selection).has(requestedOutcome)));
669
+ }
670
+ function omittedCreativeOutcomeSet(flow) {
671
+ return new Set((flow?.blueprint?.omitted ?? []).map((item) => item.outcome).filter((outcome) => typeof outcome === "string"));
672
+ }
673
+ function creativeSelectionOutcomeSet(selection) {
674
+ const outcomes = new Set();
675
+ for (const hint of selection.implementationContext.surfaceHints ?? []) {
676
+ outcomes.add(hint.outcome);
677
+ }
678
+ return outcomes;
646
679
  }
647
680
  function normalizeCreativeRequest(value) {
648
681
  return value.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").replace(/\s+/g, " ").trim();
@@ -71,6 +71,8 @@ async function inspectRoot(root) {
71
71
  ["flutter", "pubspec.yaml", "Flutter/Dart package manifest"],
72
72
  ["ios", "Podfile", "CocoaPods project manifest"],
73
73
  ["ios", "Package.swift", "Swift package manifest"],
74
+ ["ios", "project.yml", "xcodegen project manifest"],
75
+ ["ios", "project.yaml", "xcodegen project manifest"],
74
76
  ["typescript", "package.json", "JavaScript or TypeScript package manifest"],
75
77
  ["typescript", "tsconfig.json", "TypeScript project config"],
76
78
  ];
@@ -2014,6 +2016,8 @@ function validateFollowStatusSubscription(root, platform, sourceContent) {
2014
2016
  /\bobserve\s*\{/.test(checkContent) ||
2015
2017
  /\bdoOnNext\b/.test(checkContent) ||
2016
2018
  /\.subscribe\s*\(/.test(checkContent) ||
2019
+ /\$snapshot\b/.test(checkContent) ||
2020
+ /\.sink\s*[({]/.test(checkContent) ||
2017
2021
  /getFollowInfo\s*\([^()]*,/.test(checkContent) ||
2018
2022
  /\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
2019
2023
  continue;
@@ -3737,7 +3741,7 @@ function validateUnreadSubscribedNotCounted(root, platform, sourceContent) {
3737
3741
  if (/\.filter\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
3738
3742
  /\.where\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
3739
3743
  /\.count\s*\(\s*[^)]*!\s*[^)]*\.isRead\s*\)/.test(checkContent) ||
3740
- /unreadCount\s*=\s*[^;]*\bfilter\b/.test(checkContent)) {
3744
+ /unreadCount\s*=\s*[^;\n]*\bfilter\b/.test(checkContent)) {
3741
3745
  hasManualCount = true;
3742
3746
  if (!firstManualCountFile) {
3743
3747
  firstManualCountFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
@@ -3786,10 +3790,10 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
3786
3790
  return findings;
3787
3791
  }
3788
3792
  function isNonUiSourceFile(filename) {
3789
- const base = path.basename(filename).replace(/\.(kt|swift|dart|tsx?|jsx?)$/i, "");
3790
- if (/(?:Application|Manager|Repository|Repo|Service|DataSource|Datasource|Provider|Client|Store|Mapper|Interactor|UseCase|Bloc|Cubit|Notifier|Mock|Fake|Stub|Test|Tests|Spec)$/.test(base))
3793
+ const base = path.basename(filename).replace(/\.(kt|kts|java|swift|dart|tsx?|jsx?)$/i, "");
3794
+ if (/(?:Application|Manager|Repository|Repo|Service|DataSource|Datasource|Provider|Client|Store|Mapper|Interactor|UseCase|Bloc|Cubit|Notifier|Gateway|ViewModel|Mock|Fake|Stub|Test|Tests|Spec)$/.test(base))
3791
3795
  return true;
3792
- if (/_(?:manager|repository|repo|service|data_source|datasource|provider|client|store|mapper|interactor|use_case|usecase|bloc|cubit|notifier|mock|fake|stub|test|spec)$/i.test(base))
3796
+ if (/_(?:manager|repository|repo|service|data_source|datasource|provider|client|store|mapper|interactor|use_case|usecase|bloc|cubit|notifier|gateway|view_model|viewmodel|mock|fake|stub|test|spec)$/i.test(base))
3793
3797
  return true;
3794
3798
  if (/^(?:store|stores|repository|repositories|repo|repos|service|services|datasource|datasources|provider|providers|client|clients|interactor|interactors|usecase|usecases|bloc|cubit|notifier|api|mapper)$/i.test(base))
3795
3799
  return true;
@@ -3797,6 +3801,36 @@ function isNonUiSourceFile(filename) {
3797
3801
  return true;
3798
3802
  return false;
3799
3803
  }
3804
+ function viseGuardAssertionTrusted(comment, sourceContent, realGuardTest) {
3805
+ const fileTokens = comment.match(/\b[A-Za-z_][\w]*\.(?:swift|kt|kts|java|dart|tsx?|jsx?)\b/g) ?? [];
3806
+ if (fileTokens.length === 0)
3807
+ return true;
3808
+ const wanted = new Set(fileTokens);
3809
+ for (const [file, content] of sourceContent) {
3810
+ if (wanted.has(path.basename(file)) && realGuardTest(file, content))
3811
+ return true;
3812
+ }
3813
+ return false;
3814
+ }
3815
+ function hasRealBanGuard(content) {
3816
+ const banUseSurface = content.replace(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|fun|func|const|let|var|val)\s+\w*[Bb]an\w*\b/g, " ");
3817
+ return (/\bis\w*Ban(?:ned)?\b/.test(banUseSurface) ||
3818
+ /\b\w*[Ii]sBanned\b/.test(banUseSurface) ||
3819
+ /isCurrentUserBannedFromCommunity/.test(content) ||
3820
+ /community\.bannedUserIds\.(?:contains|includes)/i.test(content) ||
3821
+ /channel\.isMuted/.test(content) ||
3822
+ /member\.isMuted/.test(content) ||
3823
+ /user\.banState/.test(content));
3824
+ }
3825
+ function hasRealOwnershipGuard(stripped) {
3826
+ return (/\bisAuthor\b/.test(stripped) ||
3827
+ /\bisOwner\b/.test(stripped) ||
3828
+ /postedUserId\s*===?=?\s*\w+/.test(stripped) ||
3829
+ /\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(stripped) ||
3830
+ /creator\s*\??\.\s*userId\s*===?=?/.test(stripped) ||
3831
+ /\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(stripped) ||
3832
+ /\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(stripped));
3833
+ }
3800
3834
  function validateUserBanStateRespected(root, platform, sourceContent) {
3801
3835
  const findings = [];
3802
3836
  const ruleId = platform + '.user.ban-state-respected';
@@ -3818,7 +3852,10 @@ function validateUserBanStateRespected(root, platform, sourceContent) {
3818
3852
  /channel\.isMuted/.test(text) ||
3819
3853
  /member\.isMuted/.test(text) ||
3820
3854
  /user\.banState/.test(text) ||
3821
- /\/\/\s*vise:\s*ban state checked at/i.test(text);
3855
+ (() => {
3856
+ const m = text.match(/\/\/\s*vise:\s*ban state checked\b[^\n]*/i);
3857
+ return m ? viseGuardAssertionTrusted(m[0], sourceContent, (_f, c) => hasRealBanGuard(c)) : false;
3858
+ })();
3822
3859
  if (!hasBanCheck) {
3823
3860
  findings.push(finding(ruleId, "warning", "File " + rel + " renders an interaction action without checking ban state.", rel, "Banned users still see post-create, like, comment, or send-message buttons, which fail on the server. To avoid poor UX and support tickets, check the ban state before showing these actions."));
3824
3861
  }
@@ -3884,7 +3921,10 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
3884
3921
  /creator\s*\??\.\s*userId\s*===?=?/.test(stripped) ||
3885
3922
  /\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(stripped) ||
3886
3923
  /\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(stripped) ||
3887
- /\/\/\s*vise:\s*owner action\b/i.test(text);
3924
+ (() => {
3925
+ const m = text.match(/\/\/\s*vise:\s*owner action\b[^\n]*/i);
3926
+ return m ? viseGuardAssertionTrusted(m[0], sourceContent, (f, c) => hasRealOwnershipGuard(commentStripped(f, platform, c))) : false;
3927
+ })();
3888
3928
  if (hasModOnlyAction && !hasRoleCheck) {
3889
3929
  findings.push(finding(ruleId, "warning", "File " + rel + " invokes a moderator-only action (ban/mute) without a role check.", rel, "Ban/mute require the moderator role and fail on the server otherwise. Gate the action behind a role check (e.g. currentUser.roles.includes('moderator'), or client.hasPermission(...))."));
3890
3930
  }
@@ -3986,7 +4026,7 @@ function validatePostBodyRendered(root, platform, sourceContent) {
3986
4026
  typescript: /\bpost\s*\??\.\s*(?:text|description|caption)\b|\bpost\s*\??\.\s*data[\s\S]{0,140}\b(?:text|description|caption)\b|\b(?:render|format|get|build)(?:Post)?(?:Body|Text|Description|Caption)\s*\(\s*post\s*\)|<\s*(?:PostBody|PostText|PostDescription|PostContent)\b[^>]*\bpost=/i,
3987
4027
  "react-native": /\bpost\s*\??\.\s*(?:text|description|caption)\b|\bpost\s*\??\.\s*data[\s\S]{0,140}\b(?:text|description|caption)\b|\b(?:render|format|get|build)(?:Post)?(?:Body|Text|Description|Caption)\s*\(\s*post\s*\)|<\s*(?:PostBody|PostText|PostDescription|PostContent)\b[^>]*\bpost=/i,
3988
4028
  flutter: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*getData\s*<|\bAmityTextPost\b|\bAmityPostData\b|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
3989
- ios: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*getData\(\)\s*as\?[\s\S]{0,80}\bAmityPost\.Data\.TEXT\b|\bgetText\s*\(\s*\)|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
4029
+ ios: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*postData\b|\bpost\s*\.\s*data\s*\??\s*\[\s*["']text["']\s*\]|\bpost\s*\.\s*getData\(\)\s*as\?[\s\S]{0,80}\bAmityPost\.Data\.TEXT\b|\bgetText\s*\(\s*\)|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
3990
4030
  android: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*getData\(\)\s*as\??[\s\S]{0,80}\bAmityPost\.Data\.TEXT\b|\bAmityPost\.Data\.TEXT\b|\bgetText\s*\(\s*\)|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
3991
4031
  };
3992
4032
  const uiPat = uiTextPat[platform] ?? uiTextPat.typescript;
@@ -4521,7 +4561,7 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
4521
4561
  const findings = [];
4522
4562
  const ruleId = `${platform}.feed.target-type-explicit`;
4523
4563
  const TARGET_TYPE_PATTERNS = [
4524
- /targetType\s*[:=\(]\s*(?:co\.amity\.android\.sdk\.)?(AmityPostTargetType\.|TargetType\.|\.community|\.user|\.my_feed|\.COMMUNITY|\.USER|\.MY_FEED|"COMMUNITY"|"USER"|"MY_FEED"|'COMMUNITY'|'USER'|'MY_FEED')/i
4564
+ /targetType\s*[:=\(]\s*(?:co\.amity\.android\.sdk\.)?(AmityPostTargetType\.|TargetType\.|\.community|\.user|\.global|\.my_feed|\.COMMUNITY|\.USER|\.GLOBAL|\.MY_FEED|"community"|"user"|"global"|"my_feed"|"COMMUNITY"|"USER"|"GLOBAL"|"MY_FEED"|'community'|'user'|'global'|'my_feed'|'COMMUNITY'|'USER'|'GLOBAL'|'MY_FEED')/i
4525
4565
  ];
4526
4566
  for (const [filename, text] of sourceContent) {
4527
4567
  const rel = relativeFile(root, filename);
@@ -4538,14 +4578,16 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
4538
4578
  while ((ttm = g.exec(text)) !== null) {
4539
4579
  const window = text.slice(Math.max(0, ttm.index - 180), ttm.index + 180);
4540
4580
  const isReadQuery = /\b(?:feedType|sortBy)\s*:/.test(window) && !/\bdataType\s*:/.test(window);
4581
+ const targetTypeExpression = text.slice(ttm.index, Math.min(text.length, ttm.index + 100));
4582
+ const invalidWriteTarget = /targetType\s*[:=\(][^,)\n}]*\b(?:global|my_feed)\b/i.test(targetTypeExpression);
4541
4583
  const targetIdDynamic = /\btargetId\s*:\s*[A-Za-z_$]/.test(window);
4542
- if (!isReadQuery && !targetIdDynamic) {
4584
+ if (!isReadQuery && (invalidWriteTarget || !targetIdDynamic)) {
4543
4585
  flagged = true;
4544
4586
  break;
4545
4587
  }
4546
4588
  }
4547
4589
  if (flagged) {
4548
- findings.push(finding(ruleId, "warning", `A createPost call sets a literal targetType but its targetId is not bound to a dynamic value (route param, auth context, or prop).`, rel, "Feed targets must be bound to the intake-resolved target — the community ID from route params, the user ID from auth context, or a prop passed from the caller. Do not leave targetType as a free variable or default enum value: the intent is to wire it to 'requiredInputs.concrete feed target', not simply to avoid a literal. If this is intentional, add comment // vise: target-type rationale — <reason>."));
4590
+ findings.push(finding(ruleId, "warning", `A createPost call uses an invalid or unbound targetType/targetId pair.`, rel, "Feed targets must be bound to the intake-resolved read/write target — the community ID from route params, the user ID from auth context, or a prop passed from the caller. Do not use global as a createPost target, and do not leave targetType as a free variable or default enum value. If this is intentional, add comment // vise: target-type rationale — <reason>."));
4549
4591
  break;
4550
4592
  }
4551
4593
  }
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { createHash } from "node:crypto";
2
3
  import { promises as fs } from "node:fs";
3
4
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
4
5
  function escapeRegExp(s) {
@@ -66,6 +67,31 @@ async function loadSmokeConfig(repoRoot) {
66
67
  return null;
67
68
  }
68
69
  }
70
+ const RUNTIME_PROOF_WAIVER_PATH = ["sp-vise", "evidence", "runtime-proof-waiver.json"];
71
+ export const MIN_WAIVER_REASON_CHARS = 8;
72
+ export async function readRuntimeProofWaiver(repoRoot) {
73
+ try {
74
+ const parsed = JSON.parse(await fs.readFile(path.join(repoRoot, ...RUNTIME_PROOF_WAIVER_PATH), "utf8"));
75
+ if ((parsed.mode !== "declined" && parsed.mode !== "deviceless") || typeof parsed.reason !== "string" || parsed.reason.trim().length < MIN_WAIVER_REASON_CHARS) {
76
+ return null;
77
+ }
78
+ return { mode: parsed.mode, reason: parsed.reason.trim(), recorded_at: typeof parsed.recorded_at === "string" ? parsed.recorded_at : "" };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ export async function writeRuntimeProofWaiver(repoRoot, mode, reason, recordedAt) {
85
+ const trimmed = reason.trim();
86
+ if (trimmed.length < MIN_WAIVER_REASON_CHARS) {
87
+ throw new Error(`A runtime-proof waiver requires an auditable reason of at least ${MIN_WAIVER_REASON_CHARS} characters (got ${trimmed.length}).`);
88
+ }
89
+ const waiver = { mode, reason: trimmed, recorded_at: recordedAt };
90
+ const target = path.join(repoRoot, ...RUNTIME_PROOF_WAIVER_PATH);
91
+ await fs.mkdir(path.dirname(target), { recursive: true });
92
+ await fs.writeFile(target, `${JSON.stringify(waiver, null, 2)}\n`, "utf8");
93
+ return waiver;
94
+ }
69
95
  function smokeCaptureHelp() {
70
96
  return [
71
97
  "Create `sp-vise/smoke.json` with the mounted collection surfaces, for example `{ \"platform\": \"android\", \"surfaces\": [{ \"id\": \"feed\", \"expect\": \"populated\" }] }`.",
@@ -75,6 +101,115 @@ function smokeCaptureHelp() {
75
101
  "Then run `vise smoke . --log sp-vise/evidence/runtime-smoke.log` and rerun `vise check --ci`.",
76
102
  ].join(" ");
77
103
  }
104
+ function digestText(value) {
105
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
106
+ }
107
+ function pathInside(root, candidate) {
108
+ const relative = path.relative(root, candidate);
109
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
110
+ }
111
+ function repoRelativePath(repoRoot, filePath) {
112
+ return path.relative(repoRoot, filePath).split(path.sep).join(path.posix.sep);
113
+ }
114
+ function shouldSkipDir(name) {
115
+ return new Set([
116
+ ".git",
117
+ ".idea",
118
+ ".vscode",
119
+ "node_modules",
120
+ "dist",
121
+ "build",
122
+ ".next",
123
+ ".nuxt",
124
+ ".dart_tool",
125
+ ".gradle",
126
+ "Pods",
127
+ "DerivedData",
128
+ "sp-vise",
129
+ ]).has(name);
130
+ }
131
+ function isSourceFile(filePath) {
132
+ return /\.(?:ts|tsx|js|jsx|mjs|cjs|swift|kt|kts|java|dart)$/i.test(filePath);
133
+ }
134
+ async function collectSourceFiles(root, limit = 800) {
135
+ const found = [];
136
+ async function walk(dir) {
137
+ if (found.length >= limit)
138
+ return;
139
+ let entries;
140
+ try {
141
+ entries = await fs.readdir(dir, { withFileTypes: true });
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ for (const entry of entries) {
147
+ if (found.length >= limit)
148
+ break;
149
+ const full = path.join(dir, entry.name);
150
+ if (entry.isDirectory()) {
151
+ if (!shouldSkipDir(entry.name))
152
+ await walk(full);
153
+ }
154
+ else if (entry.isFile() && isSourceFile(full)) {
155
+ found.push(full);
156
+ }
157
+ }
158
+ }
159
+ await walk(root);
160
+ return found;
161
+ }
162
+ export function hasExecutableSmokeMarker(content) {
163
+ for (const line of content.split(/\r?\n/)) {
164
+ const idx = line.indexOf("VISE_SMOKE");
165
+ if (idx < 0)
166
+ continue;
167
+ if (/^\s*(\/\/|\*|#|<!--|\/\*)/.test(line))
168
+ continue;
169
+ const before = line.slice(0, idx);
170
+ if ((before.match(/["'`]/g) || []).length % 2 === 1)
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+ async function collectSmokeSourceProvenance(repoRoot, surfaces) {
176
+ const files = await collectSourceFiles(repoRoot);
177
+ const markerFiles = [];
178
+ const surfaceFiles = new Map();
179
+ for (const file of files) {
180
+ let content;
181
+ try {
182
+ content = await fs.readFile(file, "utf8");
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ if (!hasExecutableSmokeMarker(content)) {
188
+ continue;
189
+ }
190
+ const matchedSurfaces = surfaces
191
+ .map((surface) => surface.id)
192
+ .filter((id) => content.includes(id));
193
+ if (matchedSurfaces.length === 0) {
194
+ continue;
195
+ }
196
+ const relative = repoRelativePath(repoRoot, file);
197
+ markerFiles.push({
198
+ path: relative,
199
+ sha256: digestText(content),
200
+ surfaces: matchedSurfaces.sort(),
201
+ });
202
+ for (const surface of matchedSurfaces) {
203
+ surfaceFiles.set(surface, [...(surfaceFiles.get(surface) ?? []), relative]);
204
+ }
205
+ }
206
+ const surfaceEmitters = surfaces.map((surface) => ({
207
+ surface: surface.id,
208
+ files: (surfaceFiles.get(surface.id) ?? []).sort(),
209
+ }));
210
+ const missingSurfaces = surfaceEmitters.filter((item) => item.files.length === 0).map((item) => item.surface);
211
+ return { fingerprints: markerFiles.sort((a, b) => a.path.localeCompare(b.path)), surfaceEmitters, missingSurfaces };
212
+ }
78
213
  export const smokeTool = {
79
214
  name: "smoke",
80
215
  description: "Assess a captured runtime mount-smoke log (VISE_SMOKE markers) into a pass/fail verdict and record runtime-smoke evidence. Catches the session-establish race that static check cannot.",
@@ -115,10 +250,43 @@ export const smokeTool = {
115
250
  return textResult({ status: "no-surfaces", note: only ? `Surface '${only}' is not declared in smoke.json.` : "smoke.json declares no surfaces." });
116
251
  }
117
252
  const assessment = assessSmokeLog(logText, surfaces);
253
+ const sourceProvenance = await collectSmokeSourceProvenance(repoRoot, surfaces);
254
+ if (sourceProvenance.fingerprints.length === 0 || sourceProvenance.missingSurfaces.length > 0) {
255
+ return textResult({
256
+ status: "no-source-provenance",
257
+ platform: config.platform,
258
+ results: assessment.results,
259
+ missingSurfaces: sourceProvenance.missingSurfaces,
260
+ note: "Runtime smoke log markers were not bound to source emitters. Add `VISE_SMOKE` marker code in product source for each declared surface id, rerun the app, capture logs, and rerun `vise smoke`.",
261
+ });
262
+ }
118
263
  try {
119
264
  const evidenceDir = path.join(repoRoot, "sp-vise", "evidence");
120
265
  await fs.mkdir(evidenceDir, { recursive: true });
121
- await fs.writeFile(path.join(evidenceDir, "runtime-smoke.json"), JSON.stringify({ passed: assessment.passed, platform: config.platform, results: assessment.results }, null, 2));
266
+ const canonicalLogPath = path.join(evidenceDir, "runtime-smoke.log");
267
+ if (path.resolve(logPath) !== canonicalLogPath) {
268
+ await fs.writeFile(canonicalLogPath, logText, "utf8");
269
+ }
270
+ const smokeConfigPath = path.join(repoRoot, "sp-vise", "smoke.json");
271
+ const smokeConfigText = await fs.readFile(smokeConfigPath, "utf8");
272
+ const inputLogPath = pathInside(repoRoot, logPath) ? repoRelativePath(repoRoot, logPath) : logPath;
273
+ await fs.writeFile(path.join(evidenceDir, "runtime-smoke.json"), JSON.stringify({
274
+ passed: assessment.passed,
275
+ platform: config.platform,
276
+ generated_at: new Date().toISOString(),
277
+ log: {
278
+ path: "sp-vise/evidence/runtime-smoke.log",
279
+ input_path: inputLogPath,
280
+ sha256: digestText(logText),
281
+ },
282
+ smoke_config: {
283
+ path: "sp-vise/smoke.json",
284
+ sha256: digestText(smokeConfigText),
285
+ },
286
+ source_fingerprints: sourceProvenance.fingerprints,
287
+ surface_emitters: sourceProvenance.surfaceEmitters,
288
+ results: assessment.results,
289
+ }, null, 2));
122
290
  }
123
291
  catch {
124
292
  }