@amityco/social-plus-vise 1.4.6 → 1.6.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 +35 -10
- package/README.md +35 -31
- package/dist/capabilities.js +1 -1
- package/dist/flow.js +2 -2
- package/dist/humanFormat.js +2 -0
- package/dist/outcomes.js +34 -10
- package/dist/server.js +421 -62
- package/dist/tools/compliance.js +321 -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 +257 -10
- package/dist/tools/smoke.js +169 -1
- package/docs/vise-how-it-helps.html +318 -105
- package/package.json +1 -1
- package/rules/channel-archive.yaml +136 -0
- package/rules/feed.yaml +37 -15
- package/rules/for-you-feed.yaml +110 -0
- package/rules/live-data.yaml +10 -5
- package/rules/message-search.yaml +136 -0
- package/rules/sdk-lifecycle.yaml +23 -0
- package/sdk-surface/android.json +1305 -23
- package/sdk-surface/flutter.json +2542 -2582
- package/sdk-surface/ios.json +1182 -166
- package/sdk-surface/manifest.json +32 -32
- package/sdk-surface/models.android.json +14 -4
- package/sdk-surface/models.flutter.json +2 -2
- package/sdk-surface/models.ios.json +2 -2
- package/sdk-surface/models.typescript.json +145 -145
- package/sdk-surface/typescript.json +8337 -2443
- 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
|
];
|
|
@@ -470,6 +472,9 @@ async function validateAndroid(root) {
|
|
|
470
472
|
findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
|
|
471
473
|
findings.push(...validateStories(root, "android", sourceContent));
|
|
472
474
|
findings.push(...validateSearch(root, "android", sourceContent));
|
|
475
|
+
findings.push(...validateMessageSearch(root, "android", sourceContent));
|
|
476
|
+
findings.push(...validateChannelArchive(root, "android", sourceContent));
|
|
477
|
+
findings.push(...validateForYouFeed(root, "android", sourceContent));
|
|
473
478
|
findings.push(...validateNotificationTray(root, "android", sourceContent));
|
|
474
479
|
findings.push(...validateMembershipList(root, "android", sourceContent));
|
|
475
480
|
findings.push(...validateEvents(root, "android", sourceContent));
|
|
@@ -599,6 +604,8 @@ async function validateFlutter(root) {
|
|
|
599
604
|
findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
|
|
600
605
|
findings.push(...validateStories(root, "flutter", dartContent));
|
|
601
606
|
findings.push(...validateSearch(root, "flutter", dartContent));
|
|
607
|
+
findings.push(...validateMessageSearch(root, "flutter", dartContent));
|
|
608
|
+
findings.push(...validateChannelArchive(root, "flutter", dartContent));
|
|
602
609
|
findings.push(...validateMembershipList(root, "flutter", dartContent));
|
|
603
610
|
findings.push(...validateBlockedUsers(root, "flutter", dartContent));
|
|
604
611
|
findings.push(...validatePollVoteStatusGuard(root, "flutter", dartContent));
|
|
@@ -730,6 +737,9 @@ async function validateTypeScript(root, platform) {
|
|
|
730
737
|
findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
|
|
731
738
|
findings.push(...validateStories(root, platform, sourceContent));
|
|
732
739
|
findings.push(...validateSearch(root, platform, sourceContent));
|
|
740
|
+
findings.push(...validateMessageSearch(root, platform, sourceContent));
|
|
741
|
+
findings.push(...validateChannelArchive(root, platform, sourceContent));
|
|
742
|
+
findings.push(...validateForYouFeed(root, platform, sourceContent));
|
|
733
743
|
findings.push(...validateNotificationTray(root, platform, sourceContent));
|
|
734
744
|
findings.push(...validateMembershipList(root, platform, sourceContent));
|
|
735
745
|
findings.push(...validateEvents(root, platform, sourceContent));
|
|
@@ -1860,6 +1870,200 @@ function validateSearch(root, platform, sourceContent) {
|
|
|
1860
1870
|
}
|
|
1861
1871
|
return findings;
|
|
1862
1872
|
}
|
|
1873
|
+
function validateMessageSearch(root, platform, sourceContent) {
|
|
1874
|
+
const findings = [];
|
|
1875
|
+
const ruleId = `${platform}.message-search.live-collection`;
|
|
1876
|
+
const MESSAGE_SEARCH_QUERY_PATTERNS = [
|
|
1877
|
+
/\bsearchMessages?\b/,
|
|
1878
|
+
/\bAmityMessageSearchQuery\b/,
|
|
1879
|
+
/\bAmityMessageSearchOptions\b/,
|
|
1880
|
+
/\bSearchMessageBuilder\b/,
|
|
1881
|
+
/\bSearchMessageLiveCollection\b/,
|
|
1882
|
+
];
|
|
1883
|
+
const LIST_UI_PATTERNS = [
|
|
1884
|
+
/\bmap\s*\(/,
|
|
1885
|
+
/\bFlatList\b/,
|
|
1886
|
+
/\bListView\b/,
|
|
1887
|
+
/\bRecyclerView\b/,
|
|
1888
|
+
/\bLazyColumn\b/,
|
|
1889
|
+
/\bLazyRow\b/,
|
|
1890
|
+
/\bLazyVerticalGrid\b/,
|
|
1891
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1892
|
+
/\bForEach\b/,
|
|
1893
|
+
/\bList\s*\(/,
|
|
1894
|
+
/\bsubmitList\b/,
|
|
1895
|
+
/\btableView\b/,
|
|
1896
|
+
/\bcollectionView\b/
|
|
1897
|
+
];
|
|
1898
|
+
const REACTIVE_MARKERS = [
|
|
1899
|
+
/\bgetLiveCollection\b/,
|
|
1900
|
+
/\bobserve\b/,
|
|
1901
|
+
/\bobserveOnce\b/,
|
|
1902
|
+
/\blisten\b/,
|
|
1903
|
+
/\bonData\b/,
|
|
1904
|
+
/\bStreamBuilder\b/,
|
|
1905
|
+
/\bLiveData\b/,
|
|
1906
|
+
/\bMutableLiveData\b/,
|
|
1907
|
+
/\bMediatorLiveData\b/,
|
|
1908
|
+
/collectAsState/,
|
|
1909
|
+
/observeAsState/,
|
|
1910
|
+
/Amity\w*Flow\b/,
|
|
1911
|
+
/\bPagingData\b/,
|
|
1912
|
+
/LazyPagingItems/,
|
|
1913
|
+
/collectAsLazyPagingItems/,
|
|
1914
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1915
|
+
/\$snapshots\b/,
|
|
1916
|
+
/\bPagingController\b/,
|
|
1917
|
+
/\bgetPagingData\b/,
|
|
1918
|
+
/\bsearchMessages?\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1919
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1920
|
+
];
|
|
1921
|
+
for (const [file, content] of sourceContent) {
|
|
1922
|
+
const rel = relativeFile(root, file);
|
|
1923
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1924
|
+
continue;
|
|
1925
|
+
const hasSearchQuery = MESSAGE_SEARCH_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1926
|
+
if (hasSearchQuery) {
|
|
1927
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1928
|
+
if (hasListUI) {
|
|
1929
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1930
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1931
|
+
if (!hasReactivity) {
|
|
1932
|
+
findings.push(finding(ruleId, "warning", `A message search (searchMessage/searchMessages) renders a list but no reactive/paginated markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render message-search results from the reactive, paginated Search Message LiveCollection so the list live-updates and pages. Use the live callback (TS/RN: searchMessage(params, callback)), observe the collection (iOS searchMessages(options:) + .observe / Android AmityMessageSearchQuery PagingData), or wire a PagingController with SearchMessageLiveCollection (Flutter). If a one-shot single-page search is truly intended, add comment // vise: one-shot query."));
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return findings;
|
|
1938
|
+
}
|
|
1939
|
+
function validateChannelArchive(root, platform, sourceContent) {
|
|
1940
|
+
const findings = [];
|
|
1941
|
+
const ruleId = `${platform}.channel-archive.live-collection`;
|
|
1942
|
+
const ARCHIVE_QUERY_PATTERNS = [
|
|
1943
|
+
/\bgetArchivedChannels\b/,
|
|
1944
|
+
/\bgetArchivedChannelIds\b/,
|
|
1945
|
+
];
|
|
1946
|
+
const LIST_UI_PATTERNS = [
|
|
1947
|
+
/\bmap\s*\(/,
|
|
1948
|
+
/\bFlatList\b/,
|
|
1949
|
+
/\bListView\b/,
|
|
1950
|
+
/\bRecyclerView\b/,
|
|
1951
|
+
/\bLazyColumn\b/,
|
|
1952
|
+
/\bLazyRow\b/,
|
|
1953
|
+
/\bLazyVerticalGrid\b/,
|
|
1954
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1955
|
+
/\bForEach\b/,
|
|
1956
|
+
/\bList\s*\(/,
|
|
1957
|
+
/\bsubmitList\b/,
|
|
1958
|
+
/\btableView\b/,
|
|
1959
|
+
/\bcollectionView\b/
|
|
1960
|
+
];
|
|
1961
|
+
const REACTIVE_MARKERS = [
|
|
1962
|
+
/\bgetLiveCollection\b/,
|
|
1963
|
+
/\bobserve\b/,
|
|
1964
|
+
/\bobserveOnce\b/,
|
|
1965
|
+
/\blisten\b/,
|
|
1966
|
+
/\bonData\b/,
|
|
1967
|
+
/\bStreamBuilder\b/,
|
|
1968
|
+
/\bLiveData\b/,
|
|
1969
|
+
/\bMutableLiveData\b/,
|
|
1970
|
+
/\bMediatorLiveData\b/,
|
|
1971
|
+
/collectAsState/,
|
|
1972
|
+
/observeAsState/,
|
|
1973
|
+
/Amity\w*Flow\b/,
|
|
1974
|
+
/\bPagingData\b/,
|
|
1975
|
+
/LazyPagingItems/,
|
|
1976
|
+
/collectAsLazyPagingItems/,
|
|
1977
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1978
|
+
/\$snapshots\b/,
|
|
1979
|
+
/\bPagingController\b/,
|
|
1980
|
+
/\bgetPagingData\b/,
|
|
1981
|
+
/\bgetArchivedChannels\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1982
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1983
|
+
];
|
|
1984
|
+
for (const [file, content] of sourceContent) {
|
|
1985
|
+
const rel = relativeFile(root, file);
|
|
1986
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1987
|
+
continue;
|
|
1988
|
+
const hasArchiveQuery = ARCHIVE_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1989
|
+
if (hasArchiveQuery) {
|
|
1990
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1991
|
+
if (hasListUI) {
|
|
1992
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1993
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1994
|
+
if (!hasReactivity) {
|
|
1995
|
+
findings.push(finding(ruleId, "warning", `An archived-channels view (getArchivedChannels / getArchivedChannelIds) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the archived-channels view from the getArchivedChannels LiveCollection (subscribe via its callback / observe and re-render on updates, dispose on unmount) rather than a one-shot getArchivedChannelIds fetch, so the list updates as channels are archived or unarchived. If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return findings;
|
|
2001
|
+
}
|
|
2002
|
+
function validateForYouFeed(root, platform, sourceContent) {
|
|
2003
|
+
const findings = [];
|
|
2004
|
+
if (platform === "flutter")
|
|
2005
|
+
return findings;
|
|
2006
|
+
const ruleId = `${platform}.for-you-feed.live-collection`;
|
|
2007
|
+
const FORYOU_QUERY_PATTERNS = [
|
|
2008
|
+
/\bgetForYouFeed\b/,
|
|
2009
|
+
];
|
|
2010
|
+
const LIST_UI_PATTERNS = [
|
|
2011
|
+
/\bmap\s*\(/,
|
|
2012
|
+
/\bFlatList\b/,
|
|
2013
|
+
/\bListView\b/,
|
|
2014
|
+
/\bRecyclerView\b/,
|
|
2015
|
+
/\bLazyColumn\b/,
|
|
2016
|
+
/\bLazyRow\b/,
|
|
2017
|
+
/\bLazyVerticalGrid\b/,
|
|
2018
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
2019
|
+
/\bForEach\b/,
|
|
2020
|
+
/\bList\s*\(/,
|
|
2021
|
+
/\bsubmitList\b/,
|
|
2022
|
+
/\btableView\b/,
|
|
2023
|
+
/\bcollectionView\b/
|
|
2024
|
+
];
|
|
2025
|
+
const REACTIVE_MARKERS = [
|
|
2026
|
+
/\bgetLiveCollection\b/,
|
|
2027
|
+
/\bobserve\b/,
|
|
2028
|
+
/\bobserveOnce\b/,
|
|
2029
|
+
/\blisten\b/,
|
|
2030
|
+
/\bonData\b/,
|
|
2031
|
+
/\bStreamBuilder\b/,
|
|
2032
|
+
/\bLiveData\b/,
|
|
2033
|
+
/\bMutableLiveData\b/,
|
|
2034
|
+
/\bMediatorLiveData\b/,
|
|
2035
|
+
/collectAsState/,
|
|
2036
|
+
/observeAsState/,
|
|
2037
|
+
/Amity\w*Flow\b/,
|
|
2038
|
+
/\bPagingData\b/,
|
|
2039
|
+
/LazyPagingItems/,
|
|
2040
|
+
/collectAsLazyPagingItems/,
|
|
2041
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
2042
|
+
/\$snapshots\b/,
|
|
2043
|
+
/\bPagingController\b/,
|
|
2044
|
+
/\bgetPagingData\b/,
|
|
2045
|
+
/\bgetForYouFeed\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
2046
|
+
/\bgetForYouFeed\s*\(\s*(?:\([^)]*\)\s*=>|\w+\s*=>|function\b|async\b)/,
|
|
2047
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
2048
|
+
];
|
|
2049
|
+
for (const [file, content] of sourceContent) {
|
|
2050
|
+
const rel = relativeFile(root, file);
|
|
2051
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2052
|
+
continue;
|
|
2053
|
+
const hasForYouQuery = FORYOU_QUERY_PATTERNS.some((p) => p.test(content));
|
|
2054
|
+
if (hasForYouQuery) {
|
|
2055
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
2056
|
+
if (hasListUI) {
|
|
2057
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
2058
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
2059
|
+
if (!hasReactivity) {
|
|
2060
|
+
findings.push(finding(ruleId, "warning", `A For-You feed (getForYouFeed) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Query the personalized feed via getForYouFeed and OBSERVE it as a live collection (subscribe / .observe / PagingData — never a one-shot fetch-and-map), so the feed updates as ranking and freshness change. Gate on getForYouFeedSetting and, when disabled, fall back to the global feed rather than rendering empty. If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return findings;
|
|
2066
|
+
}
|
|
1863
2067
|
function validateNotificationTray(root, platform, sourceContent) {
|
|
1864
2068
|
const findings = [];
|
|
1865
2069
|
const ruleId = `${platform}.notification-tray.live-collection`;
|
|
@@ -2014,6 +2218,8 @@ function validateFollowStatusSubscription(root, platform, sourceContent) {
|
|
|
2014
2218
|
/\bobserve\s*\{/.test(checkContent) ||
|
|
2015
2219
|
/\bdoOnNext\b/.test(checkContent) ||
|
|
2016
2220
|
/\.subscribe\s*\(/.test(checkContent) ||
|
|
2221
|
+
/\$snapshot\b/.test(checkContent) ||
|
|
2222
|
+
/\.sink\s*[({]/.test(checkContent) ||
|
|
2017
2223
|
/getFollowInfo\s*\([^()]*,/.test(checkContent) ||
|
|
2018
2224
|
/\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
|
|
2019
2225
|
continue;
|
|
@@ -3158,6 +3364,9 @@ async function validateIos(root) {
|
|
|
3158
3364
|
findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
|
|
3159
3365
|
findings.push(...validateStories(root, "ios", swiftContent));
|
|
3160
3366
|
findings.push(...validateSearch(root, "ios", swiftContent));
|
|
3367
|
+
findings.push(...validateMessageSearch(root, "ios", swiftContent));
|
|
3368
|
+
findings.push(...validateChannelArchive(root, "ios", swiftContent));
|
|
3369
|
+
findings.push(...validateForYouFeed(root, "ios", swiftContent));
|
|
3161
3370
|
findings.push(...validateNotificationTray(root, "ios", swiftContent));
|
|
3162
3371
|
findings.push(...validateMembershipList(root, "ios", swiftContent));
|
|
3163
3372
|
findings.push(...validateEvents(root, "ios", swiftContent));
|
|
@@ -3737,7 +3946,7 @@ function validateUnreadSubscribedNotCounted(root, platform, sourceContent) {
|
|
|
3737
3946
|
if (/\.filter\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
|
|
3738
3947
|
/\.where\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
|
|
3739
3948
|
/\.count\s*\(\s*[^)]*!\s*[^)]*\.isRead\s*\)/.test(checkContent) ||
|
|
3740
|
-
/unreadCount\s*=\s*[
|
|
3949
|
+
/unreadCount\s*=\s*[^;\n]*\bfilter\b/.test(checkContent)) {
|
|
3741
3950
|
hasManualCount = true;
|
|
3742
3951
|
if (!firstManualCountFile) {
|
|
3743
3952
|
firstManualCountFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
@@ -3786,10 +3995,10 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
|
|
|
3786
3995
|
return findings;
|
|
3787
3996
|
}
|
|
3788
3997
|
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))
|
|
3998
|
+
const base = path.basename(filename).replace(/\.(kt|kts|java|swift|dart|tsx?|jsx?)$/i, "");
|
|
3999
|
+
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
4000
|
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))
|
|
4001
|
+
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
4002
|
return true;
|
|
3794
4003
|
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
4004
|
return true;
|
|
@@ -3797,6 +4006,36 @@ function isNonUiSourceFile(filename) {
|
|
|
3797
4006
|
return true;
|
|
3798
4007
|
return false;
|
|
3799
4008
|
}
|
|
4009
|
+
function viseGuardAssertionTrusted(comment, sourceContent, realGuardTest) {
|
|
4010
|
+
const fileTokens = comment.match(/\b[A-Za-z_][\w]*\.(?:swift|kt|kts|java|dart|tsx?|jsx?)\b/g) ?? [];
|
|
4011
|
+
if (fileTokens.length === 0)
|
|
4012
|
+
return true;
|
|
4013
|
+
const wanted = new Set(fileTokens);
|
|
4014
|
+
for (const [file, content] of sourceContent) {
|
|
4015
|
+
if (wanted.has(path.basename(file)) && realGuardTest(file, content))
|
|
4016
|
+
return true;
|
|
4017
|
+
}
|
|
4018
|
+
return false;
|
|
4019
|
+
}
|
|
4020
|
+
function hasRealBanGuard(content) {
|
|
4021
|
+
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, " ");
|
|
4022
|
+
return (/\bis\w*Ban(?:ned)?\b/.test(banUseSurface) ||
|
|
4023
|
+
/\b\w*[Ii]sBanned\b/.test(banUseSurface) ||
|
|
4024
|
+
/isCurrentUserBannedFromCommunity/.test(content) ||
|
|
4025
|
+
/community\.bannedUserIds\.(?:contains|includes)/i.test(content) ||
|
|
4026
|
+
/channel\.isMuted/.test(content) ||
|
|
4027
|
+
/member\.isMuted/.test(content) ||
|
|
4028
|
+
/user\.banState/.test(content));
|
|
4029
|
+
}
|
|
4030
|
+
function hasRealOwnershipGuard(stripped) {
|
|
4031
|
+
return (/\bisAuthor\b/.test(stripped) ||
|
|
4032
|
+
/\bisOwner\b/.test(stripped) ||
|
|
4033
|
+
/postedUserId\s*===?=?\s*\w+/.test(stripped) ||
|
|
4034
|
+
/\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(stripped) ||
|
|
4035
|
+
/creator\s*\??\.\s*userId\s*===?=?/.test(stripped) ||
|
|
4036
|
+
/\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(stripped) ||
|
|
4037
|
+
/\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(stripped));
|
|
4038
|
+
}
|
|
3800
4039
|
function validateUserBanStateRespected(root, platform, sourceContent) {
|
|
3801
4040
|
const findings = [];
|
|
3802
4041
|
const ruleId = platform + '.user.ban-state-respected';
|
|
@@ -3818,7 +4057,10 @@ function validateUserBanStateRespected(root, platform, sourceContent) {
|
|
|
3818
4057
|
/channel\.isMuted/.test(text) ||
|
|
3819
4058
|
/member\.isMuted/.test(text) ||
|
|
3820
4059
|
/user\.banState/.test(text) ||
|
|
3821
|
-
|
|
4060
|
+
(() => {
|
|
4061
|
+
const m = text.match(/\/\/\s*vise:\s*ban state checked\b[^\n]*/i);
|
|
4062
|
+
return m ? viseGuardAssertionTrusted(m[0], sourceContent, (_f, c) => hasRealBanGuard(c)) : false;
|
|
4063
|
+
})();
|
|
3822
4064
|
if (!hasBanCheck) {
|
|
3823
4065
|
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
4066
|
}
|
|
@@ -3884,7 +4126,10 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
|
|
|
3884
4126
|
/creator\s*\??\.\s*userId\s*===?=?/.test(stripped) ||
|
|
3885
4127
|
/\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(stripped) ||
|
|
3886
4128
|
/\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(stripped) ||
|
|
3887
|
-
|
|
4129
|
+
(() => {
|
|
4130
|
+
const m = text.match(/\/\/\s*vise:\s*owner action\b[^\n]*/i);
|
|
4131
|
+
return m ? viseGuardAssertionTrusted(m[0], sourceContent, (f, c) => hasRealOwnershipGuard(commentStripped(f, platform, c))) : false;
|
|
4132
|
+
})();
|
|
3888
4133
|
if (hasModOnlyAction && !hasRoleCheck) {
|
|
3889
4134
|
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
4135
|
}
|
|
@@ -3986,7 +4231,7 @@ function validatePostBodyRendered(root, platform, sourceContent) {
|
|
|
3986
4231
|
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
4232
|
"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
4233
|
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,
|
|
4234
|
+
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
4235
|
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
4236
|
};
|
|
3992
4237
|
const uiPat = uiTextPat[platform] ?? uiTextPat.typescript;
|
|
@@ -4521,7 +4766,7 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
|
|
|
4521
4766
|
const findings = [];
|
|
4522
4767
|
const ruleId = `${platform}.feed.target-type-explicit`;
|
|
4523
4768
|
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
|
|
4769
|
+
/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
4770
|
];
|
|
4526
4771
|
for (const [filename, text] of sourceContent) {
|
|
4527
4772
|
const rel = relativeFile(root, filename);
|
|
@@ -4538,14 +4783,16 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
|
|
|
4538
4783
|
while ((ttm = g.exec(text)) !== null) {
|
|
4539
4784
|
const window = text.slice(Math.max(0, ttm.index - 180), ttm.index + 180);
|
|
4540
4785
|
const isReadQuery = /\b(?:feedType|sortBy)\s*:/.test(window) && !/\bdataType\s*:/.test(window);
|
|
4786
|
+
const targetTypeExpression = text.slice(ttm.index, Math.min(text.length, ttm.index + 100));
|
|
4787
|
+
const invalidWriteTarget = /targetType\s*[:=\(][^,)\n}]*\b(?:global|my_feed)\b/i.test(targetTypeExpression);
|
|
4541
4788
|
const targetIdDynamic = /\btargetId\s*:\s*[A-Za-z_$]/.test(window);
|
|
4542
|
-
if (!isReadQuery && !targetIdDynamic) {
|
|
4789
|
+
if (!isReadQuery && (invalidWriteTarget || !targetIdDynamic)) {
|
|
4543
4790
|
flagged = true;
|
|
4544
4791
|
break;
|
|
4545
4792
|
}
|
|
4546
4793
|
}
|
|
4547
4794
|
if (flagged) {
|
|
4548
|
-
findings.push(finding(ruleId, "warning", `A createPost call
|
|
4795
|
+
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
4796
|
break;
|
|
4550
4797
|
}
|
|
4551
4798
|
}
|