@amityco/social-plus-vise 1.1.1 → 1.3.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.
@@ -764,11 +764,15 @@ export async function generateDesignReference(repoPath, contract, title) {
764
764
  .join("\n ");
765
765
  const digestShort = esc(contract.digest.slice(0, 23));
766
766
  const logoLetter = esc(title.slice(0, 1).toUpperCase());
767
+ const rootDecls = [...tokenCss.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)]
768
+ .map((m) => ` ${m[1]}: ${safeCss(m[2].trim())};`)
769
+ .join("\n");
770
+ const rootCss = rootDecls ? `:root{\n${rootDecls}\n}` : "";
767
771
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"/>
768
772
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
769
773
  <title>${esc(title)} — Design System</title>
770
774
  <style>
771
- ${tokenCss}
775
+ ${rootCss}
772
776
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
773
777
  html{scroll-behavior:smooth}
774
778
  body{
@@ -1094,6 +1098,8 @@ export async function runDesignCheck(repoPath) {
1094
1098
  const declaredTokens = contract.tokens.filter((token) => token.provenance === "declared");
1095
1099
  const contractColorValues = new Set(contract.tokens.filter((token) => token.category === "color").map((token) => token.value));
1096
1100
  const referenced = new Set();
1101
+ const referencedInApp = new Set();
1102
+ const seedTokensRel = SP_TOKENS_PATH.split("/").join(path.sep);
1097
1103
  const colorSample = [];
1098
1104
  const definedVars = new Set();
1099
1105
  const varRefs = [];
@@ -1115,13 +1121,18 @@ export async function runDesignCheck(repoPath) {
1115
1121
  scanned += 1;
1116
1122
  const rel = path.relative(repoRoot, file);
1117
1123
  const isCss = file.toLowerCase().endsWith(".css") || file.toLowerCase().endsWith(".scss");
1124
+ const isSeedFile = rel === SP_TOKENS_PATH || rel === seedTokensRel;
1125
+ const contentLower = content.toLowerCase();
1118
1126
  for (const token of declaredTokens) {
1119
1127
  const key = tokenKey(token);
1120
- if (referenced.has(key)) {
1128
+ if (referenced.has(key) && referencedInApp.has(key)) {
1121
1129
  continue;
1122
1130
  }
1123
- if ((token.name && content.includes(token.name)) || content.includes(token.value)) {
1131
+ if ((token.name && content.includes(token.name)) || contentLower.includes(token.value.toLowerCase())) {
1124
1132
  referenced.add(key);
1133
+ if (!isSeedFile) {
1134
+ referencedInApp.add(key);
1135
+ }
1125
1136
  }
1126
1137
  }
1127
1138
  for (const value of scanColorLiterals(content)) {
@@ -1144,12 +1155,19 @@ export async function runDesignCheck(repoPath) {
1144
1155
  }
1145
1156
  const referencedTokens = declaredTokens.filter((token) => referenced.has(tokenKey(token))).map((token) => token.name ?? token.value);
1146
1157
  const unreferencedTokens = declaredTokens.filter((token) => !referenced.has(tokenKey(token))).map((token) => token.name ?? token.value);
1158
+ const seededOnlyTokens = declaredTokens
1159
+ .filter((token) => referenced.has(tokenKey(token)) && !referencedInApp.has(tokenKey(token)))
1160
+ .map((token) => token.name ?? token.value);
1147
1161
  const contractTokenNames = new Set(contract.tokens.map((token) => token.name).filter((name) => Boolean(name)));
1148
1162
  const undefinedRefs = dedupeByToken(varRefs.filter((ref) => !definedVars.has(ref.token) && !contractTokenNames.has(ref.token)));
1149
1163
  return {
1150
1164
  status: "advisory",
1151
1165
  message: `Checked ${scanned} source file(s) against design contract ${contract.digest}. ` +
1152
- `${referencedTokens.length}/${declaredTokens.length} declared tokens referenced; ${totalColors - onContract} of ${totalColors} color literal(s) are off-contract (review hints)` +
1166
+ `${referencedTokens.length}/${declaredTokens.length} declared tokens referenced` +
1167
+ (seededOnlyTokens.length > 0
1168
+ ? ` (${referencedTokens.length - seededOnlyTokens.length} used in your code, ${seededOnlyTokens.length} only in the Vise-seeded ${SP_TOKENS_PATH} — apply them to count)`
1169
+ : "") +
1170
+ `; ${totalColors - onContract} of ${totalColors} color literal(s) are off-contract (review hints)` +
1153
1171
  (undefinedRefs.length > 0 ? `; ${undefinedRefs.length} undefined token reference(s) (likely broken styles)` : "") +
1154
1172
  ".",
1155
1173
  contract: contractSummary(contract),
@@ -1158,6 +1176,8 @@ export async function runDesignCheck(repoPath) {
1158
1176
  referenced: referencedTokens.length,
1159
1177
  referenced_tokens: referencedTokens,
1160
1178
  unreferenced_tokens: unreferencedTokens,
1179
+ referenced_in_app: referencedTokens.length - seededOnlyTokens.length,
1180
+ seeded_only_tokens: seededOnlyTokens,
1161
1181
  },
1162
1182
  colorLiterals: {
1163
1183
  scanned_files: scanned,
@@ -160,10 +160,38 @@ export async function detectCommandSensors(repoPath, platforms) {
160
160
  });
161
161
  }
162
162
  if (platforms.includes("ios")) {
163
+ sensors.push(...(await swiftPackageSensors(root)));
163
164
  sensors.push(...(await iosBuildSensors(root)));
164
165
  }
165
166
  return sensors;
166
167
  }
168
+ async function swiftPackageSensors(root) {
169
+ if (!(await exists(path.join(root, "Package.swift")))) {
170
+ return [];
171
+ }
172
+ if (!(await commandOnPath("swift"))) {
173
+ return [
174
+ {
175
+ name: "iOS SwiftPM manifest",
176
+ command: ["swift"],
177
+ timing: "after-change",
178
+ purpose: "Parse the SwiftPM manifest after iOS SDK setup changes.",
179
+ source: "Package.swift",
180
+ skip: "Package.swift was detected, but swift is not on PATH in this environment. Install Xcode or Swift toolchain support to enable the SwiftPM manifest sensor; static iOS rule checks run regardless.",
181
+ },
182
+ ];
183
+ }
184
+ return [
185
+ {
186
+ name: "iOS SwiftPM manifest",
187
+ command: ["swift", "package", "describe", "--type", "json"],
188
+ timing: "after-change",
189
+ purpose: "Parse the SwiftPM manifest after iOS SDK setup changes without compiling or requiring simulator/signing assets.",
190
+ source: "Package.swift",
191
+ timeoutReason: "SwiftPM manifest parsing (`swift package describe`) did not finish before the sensor timeout. This is reported as a timeout (NOT a clean pass): a stall can be a slow toolchain OR an unresolvable/circular dependency. Static iOS rule checks still run; rerun with a longer `--timeout-ms` if the toolchain is just slow.",
192
+ },
193
+ ];
194
+ }
167
195
  const XCODEBUILD_ENVIRONMENT_SKIPS = [
168
196
  {
169
197
  pattern: "requires Xcode|command line tools instance",
@@ -330,8 +358,8 @@ function assessHarnessability(platforms, commandSensors, designSignalCount) {
330
358
  if (designSignalCount > 0) {
331
359
  affordances.push(`Detected ${designSignalCount} design/theme signal(s) for UI integration grounding.`);
332
360
  }
333
- if (platforms.includes("ios")) {
334
- gaps.push("iOS: static compliance rules are fully operational. No build/compile sensor is wired yet (xcodebuild environment requirements make it fragile); run-sensors will return no-sensors for iOS projects.");
361
+ if (platforms.includes("ios") && commandSensors.length === 0) {
362
+ gaps.push("iOS: static compliance rules are fully operational, but no SwiftPM or Xcode command sensor was detected for this project root.");
335
363
  }
336
364
  if (platforms.length === 0) {
337
365
  return { level: "weak", affordances, gaps };
@@ -10,6 +10,8 @@ import { detectCommandSensors } from "./harness.js";
10
10
  import { inspectProject } from "./project.js";
11
11
  import { creativeSurfaceHints, readCreativeSelection } from "./creative.js";
12
12
  import { buildUxHarness, uxHarnessPlanContext } from "./uxHarness.js";
13
+ import { recommendSolutionPath } from "../solutionPath.js";
14
+ import { recommendUIKitCustomization } from "../uikitCustomization.js";
13
15
  export const planIntegrationTool = {
14
16
  name: "plan_integration",
15
17
  description: "Create a grounded, evidence-backed implementation packet before an AI coding agent edits a customer project.",
@@ -72,7 +74,7 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
72
74
  const uxHarnessOutcome = BROAD_SOCIAL_REGEX.test(request) && !hasAnswer(answers, "feature_surface") ? undefined : outcome;
73
75
  const uxHarnessContext = uxHarness ? uxHarnessPlanContext(uxHarness, uxHarnessOutcome) : undefined;
74
76
  const platform = preferredPlatform(inspection.platforms);
75
- const supportLevel = supportFor(outcome, platform);
77
+ const baseSupportLevel = supportFor(outcome, platform);
76
78
  const sensors = await detectCommandSensors(root, inspection.platforms);
77
79
  const ctx = planContextFor({
78
80
  request,
@@ -84,6 +86,9 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
84
86
  });
85
87
  const definition = getOutcomeDefinition(outcome);
86
88
  const capabilityAvailability = await platformCapabilityAvailability(outcome, platform);
89
+ const solutionPath = recommendSolutionPath(request, answers);
90
+ const uikitCustomization = recommendUIKitCustomization({ request, answers, platform, solutionPath });
91
+ const supportLevel = supportLevelForPlan(outcome, platform, baseSupportLevel, uikitCustomization);
87
92
  const designContract = await readDesignContract(repoRoot);
88
93
  const designReview = designReviewGuidance(repoRoot, designContract, answers);
89
94
  const acceptedDesignContract = designReview.status === "accepted" ? designContract : null;
@@ -108,6 +113,8 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
108
113
  const packageJsonText = await readFile(path.join(root, "package.json"), "utf8").catch(() => undefined);
109
114
  const sdkVersion = await sdkVersionGuidance(platform, packageJsonText);
110
115
  const decisionsRequired = [
116
+ ...solutionPathDecisions(solutionPath, answers),
117
+ ...uikitCustomizationDecisions(uikitCustomization, answers),
111
118
  ...(creativeContext
112
119
  ? [
113
120
  `[selected creative variant] Carry "${creativeContext.selectedVariant.title}" into the implementation plan. If any selected experience object is intentionally deferred, state that scope decision explicitly.`,
@@ -125,8 +132,10 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
125
132
  return {
126
133
  outcome,
127
134
  platform,
135
+ solutionPath,
136
+ uikitCustomization,
128
137
  supportLevel,
129
- intent: intentFor(request, definition.interpretation),
138
+ intent: intentFor(request, definition.interpretation, uikitCustomization),
130
139
  creativeContext,
131
140
  uxHarness: uxHarnessContext,
132
141
  socialWorkplan,
@@ -136,24 +145,28 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
136
145
  implementationSteps: [
137
146
  ...creativeImplementationSteps(creativeSelection),
138
147
  ...uxHarnessImplementationSteps(uxHarnessContext),
148
+ ...solutionPathImplementationSteps(solutionPath),
149
+ ...uikitCustomizationImplementationSteps(uikitCustomization),
139
150
  ...definition.implementationSteps(ctx),
140
151
  ],
141
152
  validation: ["validate_setup", "run_sensors", ...definition.validation(platform)],
142
- nextStep: "After implementing every step above, run `vise check .` and fix findings until green. You are not done until the check passes or each finding is explicitly attested.",
153
+ nextStep: nextStepForPlan(outcome, uikitCustomization, solutionPath),
143
154
  requiredInputs: composeRequiredInputs(ctx, definition.requiredInputs(ctx)),
144
155
  targetFiles: await targetFilesFor(root, outcome, platform, inspection.designSignals),
145
156
  implementationRules: [
146
157
  ...composeImplementationRules(ctx, definition.implementationRules(ctx)),
147
158
  ...creativeImplementationRules(creativeSelection),
148
159
  ...uxHarnessImplementationRules(uxHarnessContext),
160
+ ...solutionPathImplementationRules(solutionPath),
161
+ ...uikitCustomizationImplementationRules(uikitCustomization),
149
162
  ],
150
163
  intake,
151
- docs: definition.docs(platform).filter((doc) => doc.path !== "unknown"),
164
+ docs: docsForPlan(definition.docs(platform), uikitCustomization),
152
165
  surface: inspection.selectedSurface ? { path: inspection.selectedSurface.path, platforms: inspection.selectedSurface.platforms } : undefined,
153
166
  availableSurfaces: inspection.surfaces,
154
167
  applicableRules: await applicableCompliancePlanRuleSummaries(outcome, inspection.platforms),
155
168
  sensors: sensors.map((sensor) => ({ name: sensor.name, command: sensor.command, source: sensor.source })),
156
- stopConditions: composeStopConditions(ctx, definition.stopConditions(ctx), inspection.surfaces, surfacePath, Boolean(socialWorkplan)),
169
+ stopConditions: composeStopConditions(ctx, definition.stopConditions(ctx), inspection.surfaces, surfacePath, Boolean(socialWorkplan), uikitCustomization),
157
170
  evidencePolicy: "Every implementation step must cite at least one detected file, docs page, validator rule, or required user input. If evidence is missing, stop and ask the user instead of inventing details.",
158
171
  designContract: acceptedDesignContract ? designContractGuidance(acceptedDesignContract) : undefined,
159
172
  completenessChecklist: completenessChecklistFor(outcome),
@@ -188,6 +201,137 @@ function optionalCapabilitiesFor(outcome, answers, request, availability) {
188
201
  selected: selectedOptionalCapabilityIds(outcome, answers, request, availableIds),
189
202
  };
190
203
  }
204
+ function solutionPathDecisions(solutionPath, answers) {
205
+ if (hasAnswer(answers, solutionPath.answerId)) {
206
+ return [];
207
+ }
208
+ if (!solutionPath.decision.requiredBeforeHandRolledUi) {
209
+ return [];
210
+ }
211
+ return [
212
+ `[solution path] ${solutionPath.summary} Answer with --answer ${solutionPath.answerId}=uikit, =sdk, or =hybrid before hand-rolling standard social UI.`,
213
+ ];
214
+ }
215
+ function solutionPathImplementationSteps(solutionPath) {
216
+ if (!solutionPath.decision.requiredBeforeHandRolledUi) {
217
+ return [];
218
+ }
219
+ return [
220
+ {
221
+ step: solutionPath.recommendation === "uikit"
222
+ ? "Confirm the social.plus UIKit path before building standard social UI from SDK primitives; use UIKit install/auth/customization docs as the primary implementation source. The SDK feed/chat/profile steps that follow apply ONLY to the direct-SDK (or hybrid SDK-side) route — do not hand-roll the standard surface from them when the customer is using UIKit."
223
+ : "Resolve whether this is a UIKit, SDK, or hybrid build before starting UI implementation; the SDK implementation steps that follow apply to the SDK/hybrid route, not to a UIKit build. Do not let mixed speed/customization signals collapse into a hand-rolled SDK UI by default.",
224
+ evidence: ["solutionPath", ...solutionPath.evidence],
225
+ },
226
+ ];
227
+ }
228
+ function solutionPathImplementationRules(solutionPath) {
229
+ if (solutionPath.recommendation === "sdk" && !solutionPath.decision.requiredBeforeHandRolledUi) {
230
+ return [];
231
+ }
232
+ return [
233
+ solutionPath.advisoryOnly,
234
+ ...solutionPath.implementationGuidance,
235
+ ];
236
+ }
237
+ function uikitCustomizationDecisions(uikitCustomization, answers) {
238
+ if (!uikitCustomization) {
239
+ return [];
240
+ }
241
+ if (hasAnswer(answers, uikitCustomization.answerId)) {
242
+ return [];
243
+ }
244
+ if (uikitCustomization.decision.requiredBeforeCustomization) {
245
+ return [
246
+ `[UIKit customization] ${uikitCustomization.summary} Answer with --answer ${uikitCustomization.answerId}=dynamic-ui, =component-styling, =localization, =behavior-overrides, =fork-and-extend, =sdk-custom-ui, or =hybrid before editing UIKit UI/customization code.`,
247
+ ];
248
+ }
249
+ return [
250
+ `[UIKit customization] ${uikitCustomization.summary} State this chosen route in the implementation summary, or re-plan with --answer ${uikitCustomization.answerId}=<route> if the customer chooses a different level.`,
251
+ ];
252
+ }
253
+ function uikitCustomizationImplementationSteps(uikitCustomization) {
254
+ if (!uikitCustomization) {
255
+ return [];
256
+ }
257
+ return [
258
+ {
259
+ step: `Apply the UIKit customization ladder before writing custom UI: start with ${uikitCustomization.recommendedLevel}, then escalate only if the customer goal cannot be expressed there.`,
260
+ evidence: ["uikitCustomization", ...uikitCustomization.evidence],
261
+ },
262
+ ];
263
+ }
264
+ function uikitCustomizationImplementationRules(uikitCustomization) {
265
+ if (!uikitCustomization) {
266
+ return [];
267
+ }
268
+ return [
269
+ uikitCustomization.advisoryOnly,
270
+ ...uikitCustomization.implementationGuidance,
271
+ ...uikitCustomization.platformGuidance,
272
+ ];
273
+ }
274
+ function docsForPlan(outcomeDocs, uikitCustomization) {
275
+ const docs = outcomeDocs.filter((doc) => doc.path !== "unknown");
276
+ if (!uikitCustomization) {
277
+ return docs;
278
+ }
279
+ const customizationLabel = labelForUIKitCustomizationLevel(uikitCustomization.recommendedLevel);
280
+ for (const docPath of uikitCustomization.docs) {
281
+ docs.push({
282
+ path: docPath,
283
+ reason: `UIKit ${customizationLabel} customization guidance for this solution path.`,
284
+ });
285
+ }
286
+ return dedupeDocsByPath(docs);
287
+ }
288
+ function supportLevelForPlan(outcome, platform, baseSupportLevel, uikitCustomization) {
289
+ if (platform !== "unknown" && outcome === "unknown" && uikitCustomization) {
290
+ return "guided";
291
+ }
292
+ return baseSupportLevel;
293
+ }
294
+ function nextStepForPlan(outcome, uikitCustomization, solutionPath) {
295
+ if (outcome === "unknown" && uikitCustomization) {
296
+ return "Treat this as UIKit customization guidance first: resolve the UIKit route and concrete app/platform target, then implement customer-owned config, localization, behavior, fork, or SDK custom UI changes with local build evidence. Do not claim `vise check` deterministically validates hidden UIKit internals.";
297
+ }
298
+ if (solutionPath.decision.requiredBeforeHandRolledUi) {
299
+ return "First resolve the solution-path decision (UIKit vs SDK vs hybrid). The SDK implementation steps above apply only if you build this surface directly with the SDK (or the SDK side of a hybrid); if the customer uses social.plus UIKit for the standard surface, follow the UIKit guidance instead of hand-rolling those steps. Once the route is settled, implement the applicable steps, then run `vise check .` and fix findings until green — you are not done until the check passes or each finding is explicitly attested.";
300
+ }
301
+ return "After implementing every step above, run `vise check .` and fix findings until green. You are not done until the check passes or each finding is explicitly attested.";
302
+ }
303
+ function dedupeDocsByPath(docs) {
304
+ const seen = new Set();
305
+ const out = [];
306
+ for (const doc of docs) {
307
+ if (seen.has(doc.path)) {
308
+ continue;
309
+ }
310
+ seen.add(doc.path);
311
+ out.push(doc);
312
+ }
313
+ return out;
314
+ }
315
+ function labelForUIKitCustomizationLevel(level) {
316
+ switch (level) {
317
+ case "dynamic-ui":
318
+ return "Dynamic UI";
319
+ case "component-styling":
320
+ return "Component Styling";
321
+ case "localization":
322
+ return "Localization";
323
+ case "behavior-overrides":
324
+ return "Behavior Overrides";
325
+ case "fork-and-extend":
326
+ return "Fork and Extend";
327
+ case "sdk-custom-ui":
328
+ return "SDK Custom UI";
329
+ case "hybrid":
330
+ return "Hybrid";
331
+ case "needs-decision":
332
+ return "decision";
333
+ }
334
+ }
191
335
  function creativeContextForPlan(selection) {
192
336
  const selectedVariant = selection.selectedVariant;
193
337
  const surfaceHints = creativeSurfaceHints(selection);
@@ -484,13 +628,17 @@ function designContractGuidance(contract) {
484
628
  advisoryOnly: "This contract is advisory generation guidance — it adds no deterministic enforcement and never fails `vise check`.",
485
629
  };
486
630
  }
487
- function intentFor(request, interpretation) {
631
+ function intentFor(request, interpretation, uikitCustomization) {
488
632
  const broadSocialRequest = BROAD_SOCIAL_REGEX.test(request);
489
633
  const designRequest = DESIGN_REGEX.test(request);
634
+ const uikitOnlyCustomization = interpretation === "Implement unknown." && uikitCustomization;
635
+ const uikitInterpretation = uikitOnlyCustomization
636
+ ? `${uikitCustomization.summary} No deterministic SDK outcome was selected, so keep this as guided UIKit customization until the customer names the concrete social surface or app-owned customization target.`
637
+ : interpretation;
490
638
  return {
491
639
  rawRequest: request,
492
- interpretation,
493
- ambiguity: broadSocialRequest || designRequest ? "high" : "medium",
640
+ interpretation: uikitInterpretation,
641
+ ambiguity: broadSocialRequest || designRequest || uikitCustomization?.status === "needs-decision" || uikitOnlyCustomization ? "high" : "medium",
494
642
  };
495
643
  }
496
644
  function intakeFor(ctx, outcomeQuestions, outcome, brief, availability, designReview) {
@@ -611,15 +759,18 @@ function composeImplementationRules(ctx, outcomeRules) {
611
759
  }
612
760
  return rules;
613
761
  }
614
- function composeStopConditions(ctx, outcomeStops, surfaces, surfacePath, hasSocialWorkplan = false) {
762
+ function composeStopConditions(ctx, outcomeStops, surfaces, surfacePath, hasSocialWorkplan = false, uikitCustomization) {
615
763
  const stops = [
616
764
  "A required secret is missing and no safe ignored local env file or non-secret template path is clear.",
617
765
  "The target file is ambiguous or missing and no safe conventional location is detected.",
618
766
  "Docs lookup does not return the canonical page named in this plan.",
619
767
  ];
620
- if (ctx.platform === "unknown" || ctx.outcome === "unknown") {
768
+ if (ctx.platform === "unknown" || (ctx.outcome === "unknown" && !uikitCustomization)) {
621
769
  stops.push("The request or platform is unsupported; do not implement until clarified.");
622
770
  }
771
+ if (ctx.outcome === "unknown" && uikitCustomization) {
772
+ stops.push("No deterministic SDK outcome was selected; do not run `vise init` or claim compliance coverage until a concrete social surface or app-owned UIKit customization target is confirmed.");
773
+ }
623
774
  if (ctx.platforms.length > 1 && !surfacePath) {
624
775
  stops.push(`Multiple platform signals detected (${ctx.platforms.join(", ")}); confirm which app surface should be modified.`);
625
776
  }
@@ -204,7 +204,7 @@ async function validateAndroid(root) {
204
204
  const loginFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*login/, /AmityClient\s*\.\s*login/]);
205
205
  const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
206
206
  const pushUnregisterFiles = filesMatching(sourceContent, [/unregisterPushNotification/, /disablePushNotification/, /unregister.*DeviceToken/i]);
207
- const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.observe\s*\(/, /\.subscribe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
207
+ const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.observe\s*\(/, /AmitySocialClient\s*\.\s*newPostRepository/, /\bgetPosts\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
208
208
  if (!manifest) {
209
209
  findings.push(finding("android.manifest.present", "warning", "No AndroidManifest.xml found at the default app path.", manifestPath, "Confirm the Android app module path, then validate permissions and Application wiring."));
210
210
  }
@@ -220,7 +220,7 @@ async function validateAndroid(root) {
220
220
  findings.push(finding("android.dependency.sdk", "warning", "No obvious social.plus Android SDK dependency was found in Gradle files.", relativeFile(root, buildFiles[0]), "Add the SDK dependency from the Android quick-start docs and keep all Amity dependencies on compatible versions."));
221
221
  }
222
222
  else if (containsAny(buildContent, [/co\.amity\.android:amity-sdk:\+/, /co\.amity\.android:amity-sdk:latest/i, /version\s*=\s*["']\+["']/])) {
223
- findings.push(finding("android.sdk.version.pinned", "warning", "The Android SDK dependency appears to use a floating version.", relativeFile(root, buildFiles[0]), "Pin the social.plus Android SDK to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
223
+ findings.push(finding("android.sdk.version.pinned", "warning", "The Android SDK dependency uses an uncontrolled version (e.g. latest / + / a moving branch), not a pinned version or reviewed semver range.", relativeFile(root, buildFiles[0]), "Pin the social.plus Android SDK to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
224
224
  }
225
225
  if (setupFiles.length === 0) {
226
226
  findings.push(finding("android.setup.present", "warning", "No obvious AmityCoreClient.setup call was found in Kotlin/Java files.", undefined, "Call SDK setup once during application startup before any social.plus API usage."));
@@ -317,8 +317,8 @@ async function validateFlutter(root) {
317
317
  const dartContent = await readMany(dartFiles);
318
318
  const setupFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*setup/]);
319
319
  const loginFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*login/]);
320
- const pushRegistrationFiles = filesMatching(dartContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
321
- const pushUnregisterFiles = filesMatching(dartContent, [/unregisterPushNotification/, /disablePushNotification/]);
320
+ const pushRegistrationFiles = filesMatching(dartContent, [/registerPushNotification/, /registerDeviceNotification/, /enablePushNotification/, /PushNotification/]);
321
+ const pushUnregisterFiles = filesMatching(dartContent, [/unregisterPushNotification/, /unregisterDeviceNotification/, /disablePushNotification/]);
322
322
  const liveDataFiles = filesMatching(dartContent, [/LiveCollection/, /LiveObject/, /\.listen\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
323
323
  if (!pubspec) {
324
324
  findings.push(finding("flutter.pubspec.present", "warning", "No pubspec.yaml file was found.", "pubspec.yaml", "Point repoPath at the Flutter project root."));
@@ -327,7 +327,7 @@ async function validateFlutter(root) {
327
327
  findings.push(finding("flutter.dependency.sdk", "warning", "No obvious social.plus/Amity dependency was found in pubspec.yaml.", "pubspec.yaml", "Add the Flutter SDK dependency from the Flutter quick-start docs."));
328
328
  }
329
329
  else if (/\bamity_sdk\s*:\s*(?:any|\*|latest)\b/i.test(pubspec)) {
330
- findings.push(finding("flutter.sdk.version.pinned", "warning", "The Flutter SDK dependency appears to use a floating version.", "pubspec.yaml", "Pin amity_sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
330
+ findings.push(finding("flutter.sdk.version.pinned", "warning", "The Flutter SDK dependency uses an uncontrolled version (e.g. any / a moving git ref), not a pinned version or reviewed semver range.", "pubspec.yaml", "Pin amity_sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
331
331
  }
332
332
  if (setupFiles.length === 0) {
333
333
  findings.push(finding("flutter.setup.present", "warning", "No obvious AmityCoreClient.setup call was found in lib/*.dart.", undefined, "Call AmityCoreClient.setup before any social.plus API usage."));
@@ -424,7 +424,7 @@ async function validateTypeScript(root, platform) {
424
424
  findings.push(finding("typescript.dependency.sdk", "warning", "No obvious social.plus/Amity package was found in package.json.", "package.json", "Add the TypeScript SDK dependency from the web quick-start docs."));
425
425
  }
426
426
  else if (isFloatingPackageDependency(packageJson, "@amityco/ts-sdk")) {
427
- findings.push(finding(`${platform}.sdk.version.pinned`, "warning", "The TypeScript SDK dependency appears to use a floating version.", "package.json", "Pin @amityco/ts-sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
427
+ findings.push(finding(`${platform}.sdk.version.pinned`, "warning", "The TypeScript SDK dependency uses an uncontrolled version (latest / * / x), not a pinned version or reviewed semver range (^/~ is accepted).", "package.json", "Pin @amityco/ts-sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
428
428
  }
429
429
  if (setupFiles.length === 0) {
430
430
  findings.push(finding("typescript.client.create", "warning", "No obvious TypeScript client initialization pattern was found.", undefined, "Create the social.plus client before login and before API usage."));
@@ -715,6 +715,9 @@ function validateFeedUiStates(root, platform, sourceContent) {
715
715
  if (isNonUiSourceFile(file)) {
716
716
  continue;
717
717
  }
718
+ if (platform === "android" && /\bclass\s+\w+\s*:\s*Application\s*\(/.test(content)) {
719
+ continue;
720
+ }
718
721
  const observes = observationPatterns.some((pattern) => pattern.test(content));
719
722
  if (!observes) {
720
723
  continue;
@@ -741,7 +744,7 @@ const UI_STATE_PATTERNS_BY_PLATFORM = {
741
744
  typescript: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /\.length\s*===?\s*0/, /\busestate\b/i],
742
745
  "react-native": [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /\.length\s*===?\s*0/, /ActivityIndicator/],
743
746
  flutter: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bisEmpty\b/, /CircularProgressIndicator/, /AsyncSnapshot/, /ConnectionState/],
744
- android: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /CircularProgressIndicator/, /LoadingState/, /\.collectAsState\b/],
747
+ android: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /CircularProgressIndicator/, /LoadingState/, /FeedUiState\.(?:Loading|Empty|Error)\b/, /\.collectAsState\b/],
745
748
  ios: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /ProgressView/, /LoadingState/, /AsyncImage/],
746
749
  };
747
750
  const FEED_LIST_OBSERVATION_PATTERNS_BY_PLATFORM = {
@@ -937,13 +940,18 @@ function validateComments(root, platform, sourceContent) {
937
940
  break;
938
941
  }
939
942
  }
940
- const hasReactiveCommentObserver = [...sourceContent.values()].some((c) => {
943
+ const isObserverContent = (c) => {
941
944
  const stripped = c.replace(/\bawait\b[^\n;]*?(?:get|query)Comments\s*\(/g, "");
942
945
  return /(?:get|query)Comments\s*\(/.test(stripped)
943
946
  || /CommentLiveCollection|AmityCommentCollection|commentCollection/.test(c);
944
- });
947
+ };
948
+ const observerFiles = [...sourceContent.entries()].filter(([, c]) => isObserverContent(c)).map(([f]) => f);
949
+ const hasReactiveCommentObserver = observerFiles.length > 0;
945
950
  const cleanupMarkers = COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
946
- if (commentFiles.length > 0 && hasReactiveCommentObserver && !containsAny(sourceContent, cleanupMarkers)) {
951
+ const ANDROID_OBSERVER_SELF_CLEAN = /(?:(?:get|query)Comments\s*\(|CommentLiveCollection|AmityCommentCollection|commentCollection)[^;{}]{0,300}?(?:\.stateIn\s*\([^;{}]*\bviewModelScope\b|\.asLiveData\s*\(|\.collectAsStateWithLifecycle\s*\(|\brepeatOnLifecycle\b|\bDisposableEffect\b)/;
952
+ const observerSelfCleans = platform === "android" &&
953
+ observerFiles.some((file) => ANDROID_OBSERVER_SELF_CLEAN.test(commentStripped(file, platform, sourceContent.get(file) ?? "")));
954
+ if (commentFiles.length > 0 && hasReactiveCommentObserver && !observerSelfCleans && !containsAny(sourceContent, cleanupMarkers)) {
947
955
  findings.push(finding(`${platform}.comments.observer-cleanup`, "warning", "Comment observer/subscription was found but no obvious cleanup was detected.", relativeFile(root, commentFiles[0]), "Dispose or unsubscribe comment observers when the lifecycle owner is destroyed."));
948
956
  }
949
957
  const strippedCommentFiles = new Map();
@@ -1029,6 +1037,16 @@ const CHAT_PRESENCE_PATTERNS = [
1029
1037
  /\bchatClient\b/i,
1030
1038
  /\bmessage\s*live\s*collection\b/i,
1031
1039
  ];
1040
+ const CHAT_SEND_CALL_PATTERNS = [
1041
+ /\bsendMessage\s*\(/,
1042
+ /\bcreateMessage\b/,
1043
+ /\bcreateTextMessage\b/,
1044
+ /\bcreateImageMessage\b/,
1045
+ /\bcreateFileMessage\b/,
1046
+ /\bcreateVideoMessage\b/,
1047
+ /\bcreateAudioMessage\b/,
1048
+ /\bcreateCustomMessage\b/,
1049
+ ];
1032
1050
  const CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
1033
1051
  typescript: [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
1034
1052
  "react-native": [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
@@ -1068,6 +1086,7 @@ function validateChat(root, platform, sourceContent) {
1068
1086
  const chatFileContent = new Map();
1069
1087
  for (const f of chatFiles)
1070
1088
  chatFileContent.set(f, sourceContent.get(f) ?? "");
1089
+ const hasSendCall = containsAny(chatFileContent, CHAT_SEND_CALL_PATTERNS);
1071
1090
  const hasErrorHandling = containsAny(chatFileContent, [
1072
1091
  /\bcatch\b/,
1073
1092
  /\b\.catch\b/,
@@ -1077,7 +1096,7 @@ function validateChat(root, platform, sourceContent) {
1077
1096
  /\berror\s*:/,
1078
1097
  /\btry\b/,
1079
1098
  ]);
1080
- if (!hasErrorHandling) {
1099
+ if (hasSendCall && !hasErrorHandling) {
1081
1100
  findings.push(finding(`${platform}.chat.send-error-handling`, "warning", "Message send calls found without error handling.", relativeFile(root, chatFiles[0]), "Wrap sendMessage in try/catch or handle the error callback to show send failure state."));
1082
1101
  }
1083
1102
  if (!containsAny(chatFileContent, MODERATION_MARKERS)) {
@@ -1434,7 +1453,9 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
1434
1453
  ];
1435
1454
  const FILTER_MARKERS = [
1436
1455
  /\bfeedTypes?\s*[:(]/,
1456
+ /\breviewStatus\s*[:(]/,
1437
1457
  /\bincludeDeleted\s*\(\s*false\s*\)/,
1458
+ /\bdeletedOption\s*:\s*\.notDeleted\b/,
1438
1459
  /\bisDeleted\s*:\s*false\b/,
1439
1460
  /\bisFlagged\s*:\s*false\b/,
1440
1461
  /\bstatuses\s*[:(]/,
@@ -1445,6 +1466,8 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
1445
1466
  /\/\/\s*vise:\s*moderation review feed/i
1446
1467
  ];
1447
1468
  for (const [file, content] of sourceContent) {
1469
+ if (path.basename(file).endsWith('.d.ts'))
1470
+ continue;
1448
1471
  const rel = relativeFile(root, file);
1449
1472
  const hasPostQuery = POST_QUERY_PATTERNS.some((p) => p.test(content));
1450
1473
  if (hasPostQuery) {
@@ -2296,8 +2319,11 @@ async function validateIos(root) {
2296
2319
  else if (!containsAny(manifestContent, [/Amity/i, /AmitySDK/i])) {
2297
2320
  findings.push(finding("ios.dependency.sdk", "warning", "No obvious social.plus/Amity dependency was found in Podfile or Package.swift.", relativeFile(root, manifestFiles[0]), "Add the iOS SDK dependency from the iOS quick-start docs."));
2298
2321
  }
2322
+ else if (containsAny(manifestContent, [/Amity-Social-Cloud-SDK-iOS-IPA/i])) {
2323
+ findings.push(finding("ios.dependency.swiftpm-repo", "warning", "The iOS SwiftPM dependency points at the obsolete Amity-Social-Cloud-SDK-iOS-IPA package repository.", relativeFile(root, manifestFiles[0]), "Use https://github.com/AmityCo/Amity-Social-Cloud-SDK-iOS-SwiftPM.git with product AmitySDK so SwiftPM can resolve the social.plus iOS SDK."));
2324
+ }
2299
2325
  else if (containsAny(manifestContent, [/\.package\s*\([^)]*\bbranch\s*:/s, /pod\s+['"][^'"]*Amity[^'"]*['"]\s*$/m])) {
2300
- findings.push(finding("ios.sdk.version.pinned", "warning", "The iOS SDK dependency appears to use a floating branch or unspecified CocoaPods version.", relativeFile(root, manifestFiles[0]), "Pin the social.plus iOS SDK to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
2326
+ findings.push(finding("ios.sdk.version.pinned", "warning", "The iOS SDK dependency uses an uncontrolled version (a moving branch or unspecified CocoaPods version), not a pinned version or reviewed semver range.", relativeFile(root, manifestFiles[0]), "Pin the social.plus iOS SDK to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
2301
2327
  }
2302
2328
  if (setupFiles.length === 0) {
2303
2329
  findings.push(finding("ios.setup.present", "warning", "No obvious AmityClient initialization was found in Swift files.", undefined, "Initialize AmityClient before login and before social.plus API usage."));
@@ -2944,7 +2970,7 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
2944
2970
  let hasPreferences = false;
2945
2971
  let firstPushRegistrationFile = '';
2946
2972
  for (const [filename, text] of sourceContent) {
2947
- if (/\b(registerPushNotification|enablePushNotification)\b/.test(text)) {
2973
+ if (/\b(registerPushNotification|registerDeviceNotification|enablePushNotification)\b/.test(text)) {
2948
2974
  hasPushRegistration = true;
2949
2975
  if (!firstPushRegistrationFile) {
2950
2976
  firstPushRegistrationFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
@@ -174,9 +174,16 @@ export async function getSdkFacts(options) {
174
174
  notes: [
175
175
  "Symbol facts are existence-only: Vise confirms public symbols in the normalized SDK surface, but semantic correctness and idiomatic usage still require rules, docs, and sensors.",
176
176
  modelFacts.facts
177
- ? `Field-level model facts are extraction-grounded (${modelFacts.facts.fieldsGrounding}) by ${modelFacts.facts.extraction.extractor}; names-only grounding means the source proves field names but no types.`
177
+ ? `Field-level model facts are extraction-grounded (${modelFacts.facts.fieldsGrounding}) by ${modelFacts.facts.extraction.extractor}.${modelFacts.facts.fieldsGrounding === "names-only"
178
+ ? " names-only grounding means the source proves field names but no types."
179
+ : ""}`
178
180
  : "No field-level model facts for this platform snapshot: model claims stay symbol-only (absence over fabrication).",
179
181
  "Remote SP_SDK_SURFACE_URL resolution is recorded in the manifest for a future online snapshot source; this MVP uses local override/env/bundled directories only.",
182
+ ...(platform !== "typescript"
183
+ ? [
184
+ `Capability anchors are authored TypeScript-first at this MVP, so capabilities is empty for ${platform}. This means capability mapping is not yet available on ${platform} — it does NOT mean the SDK lacks the feature. The symbol and model facts above are extracted from the ${platform} surface and remain authoritative.`,
185
+ ]
186
+ : []),
180
187
  ],
181
188
  };
182
189
  if (options.includeSymbols) {
@@ -112,7 +112,7 @@ async function runSensor(cwd, sensor, timeoutMs) {
112
112
  durationMs: Date.now() - startedAt,
113
113
  stdout: truncate(stdout),
114
114
  stderr: truncate(stderr),
115
- reason: `Timed out after ${timeoutMs}ms.`,
115
+ reason: sensor.timeoutReason ?? `Timed out after ${timeoutMs}ms.`,
116
116
  });
117
117
  }, timeoutMs);
118
118
  child.stdout?.on("data", (chunk) => {