@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.
@@ -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
  ];
@@ -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*[^;]*\bfilter\b/.test(checkContent)) {
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
- /\/\/\s*vise:\s*ban state checked at/i.test(text);
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
- /\/\/\s*vise:\s*owner action\b/i.test(text);
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 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>."));
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
  }