@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.
- package/CHANGELOG.md +33 -9
- package/README.md +65 -21
- package/dist/capabilities.js +1 -1
- package/dist/flow.js +2 -2
- package/dist/humanFormat.js +2 -0
- package/dist/outcomes.js +24 -10
- package/dist/server.js +421 -62
- package/dist/tools/compliance.js +318 -12
- package/dist/tools/debug.js +16 -1
- package/dist/tools/design.js +47 -16
- package/dist/tools/integration.js +40 -7
- package/dist/tools/project.js +52 -10
- package/dist/tools/smoke.js +169 -1
- package/docs/vise-how-it-helps.html +2187 -0
- package/package.json +3 -2
- package/rules/feed.yaml +37 -15
- package/rules/live-data.yaml +10 -5
- package/rules/sdk-lifecycle.yaml +23 -0
- package/skills/social-plus-vise/SKILL.md +33 -13
|
@@ -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
|
|
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.
|
|
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();
|
package/dist/tools/project.js
CHANGED
|
@@ -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*[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/tools/smoke.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|