@amityco/social-plus-vise 1.7.0 → 1.8.1

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 CHANGED
@@ -4,6 +4,46 @@ All notable changes to `@amityco/social-plus-vise` are documented in this file.
4
4
 
5
5
  The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.8.1 — 2026-07-11
8
+
9
+ ### Added
10
+ - **Complete React Native journey proof.** The deterministic harness now runs the full governed feed journey on TypeScript, React Native, Android, Flutter, and iOS, including every documented exit state, scope-omit recovery, attestation and source-fingerprint lifecycle, contract drift, sensors, and runtime-evidence handoff.
11
+ - **Public-preview measurement contract.** A schema-validated, metadata-only report template defines the SDK custom-UI, UIKit quick-launch, and brownfield/day-2 evaluation tracks while forbidding source upload, recorded secrets, and customer identifiers. Structural, fresh-run, and external-adoption evidence remain explicitly separate.
12
+
13
+ ### Changed
14
+ - **Locked UIKit plans stay on UIKit.** After `solution_path=uikit` is explicitly selected, the plan keeps an actionable UIKit installation/provider/customization step and omits contradictory direct-SDK outcome steps. Hybrid plans retain those steps only for differentiated behavior outside UIKit.
15
+ - **Explicit workplan surfaces are preserved.** An accepted Engagement Intelligence variant may add coherent companion surfaces, but it can no longer erase surfaces named directly by the user; bare plural `communities` is recognized in multi-surface requests.
16
+
17
+ ### Fixed
18
+ - **Safe placeholders no longer mask later hardcoded values.** Literal scanning continues past documented placeholder IDs and API keys, including later matches in the same file or project, so a benign env fallback cannot hide a real committed secret or target.
19
+ - **Public contract wording.** The npm README now reflects the Node.js 20 minimum, fail-closed scan coverage, strict sensor selection, and the `vise check --ci` all-engagement release gate.
20
+
21
+ ### Compatibility
22
+ - This patch changes no sidecar schema or exit code. SDK plans and unresolved UIKit decisions retain their existing branch visibility; only an explicitly locked UIKit route suppresses direct-SDK outcome steps.
23
+
24
+ ## 1.8.0 — 2026-07-10
25
+
26
+ ### Added
27
+ - **Complete-scan receipts and gates.** Project inspection and platform validators now report bounded scan coverage. A truncated inventory cannot initialize a compliance sidecar, select sensors, or produce a green `vise check`; large monorepos must select the concrete app surface.
28
+ - **Pre-launch release contracts.** Hosted CI now smokes the declared Node 20 minimum, release metadata/docs are regression-tested, and tag publishing uses npm trusted publishing with OIDC, a modern npm CLI, main-ancestry validation, and no long-lived token fallback.
29
+ - **Critical regression coverage.** New tests lock scan truncation, mixed-platform inventory detection, strict sensor selection, bounded command output, descendant cleanup on timeout, block mount proof, and the release workflow contract.
30
+
31
+ ### Changed
32
+ - **CI covers finished work by default.** `vise check --ci` now runs the active gate plus every recorded engagement re-verdict. A previously completed surface that drifted exits `11` even when the active engagement is green; explicit `--all-engagements` remains valid with `--ci`.
33
+ - **Block capabilities require a live mount.** The blocks sidecar is reset to `2026-07-10.vise-blocks-sidecar.v2`. `blocks add --apply` records `pending-mount`; `vise check` counts `providesCapabilities` only while the dependency, touched files, package import, and actual block-symbol usage remain present. `blocks validate` persists `verified` or `invalid` and exits non-zero on failure.
34
+ - **Explicit sensor selection is strict.** A requested `--include` name that does not match a detected sensor returns `invalid-selection` and exits non-zero. Sensor stdout/stderr is bounded, and timeouts terminate the command process group rather than leaving descendants running.
35
+ - **Node.js 20 is the minimum supported runtime.** Package metadata, `vise doctor`, release documentation, and hosted compatibility proof now agree.
36
+ - **The compatibility corpus is reset to the 1.8.0 launch contract.** Experimental pre-launch sidecars are not grandfathered; future published versions must remain backward-readable from this baseline.
37
+
38
+ ### Fixed
39
+ - **Ducati dogfood false positives.** Built-in `{ text }` shorthand payloads no longer look like undeclared custom post types, and comment modules that carry a bare `postId` no longer look like post cards missing their body.
40
+ - **Engagement-aware evidence hygiene.** Matching attestations retained by completed engagement contracts are no longer reported as generic stale files. Off-contract attestations with changed source fingerprints are identified explicitly.
41
+ - **Flutter hosted block dependencies.** Hosted constraints now render as `social_plus_blocks: ^0.1.0`; only local sources render a nested `path:` dependency.
42
+ - **Fixture-symmetry parsing.** The supplementary symmetry gate parses only `failingFixturesByFinding`, so later no-fire maps cannot overwrite the failing fixture associated with a rule.
43
+
44
+ ### Compatibility
45
+ - This is an intentional pre-launch contract hardening release. The stricter scan, sensor, block-mount, Node, CI, and sidecar behaviors differ from the experimental 1.7.0 line, but Vise and Blocks have not launched and have no customer contract to migrate. The release version remains 1.8.0 as requested; the new sidecar schema keeps its independent `.v2` schema identifier.
46
+
7
47
  ## 1.7.0 — 2026-07-09
8
48
 
9
49
  ### Added
package/README.md CHANGED
@@ -66,6 +66,8 @@ It ships as three layers:
66
66
 
67
67
  > **New to social.plus?** Vise integrates the social.plus SDK. To adopt social.plus, [talk to our team](https://www.social.plus/contact/contact-sales?utm_source=vise&utm_medium=npm-readme&utm_campaign=ga). Existing customers get their **API key + region** from the [social.plus Console](https://console.socialplus.com) and can follow the [SDK getting-started guide](https://learn.social.plus/social-plus-sdk/getting-started/overview). You can install Vise and explore `vise inspect`/`vise plan` before you have credentials.
68
68
 
69
+ > **Runtime requirement:** Vise requires Node.js 20 or newer. Run `node --version` before installing if you are unsure which runtime your terminal or CI environment uses.
70
+
69
71
  ```sh
70
72
  # 1. Install the CLI
71
73
  npm install -g @amityco/social-plus-vise
@@ -202,7 +204,7 @@ Run `vise <command> --help` for full flags. JSON output is the default for agent
202
204
  |---|---|
203
205
  | `vise explore "<request>"` | Pre-credentials discovery: map a request to what social.plus offers (candidate outcome, capabilities, docs, next command). No project or API key needed |
204
206
  | `vise doctor` | Verify install; print version, install path, docs source |
205
- | `vise inspect [path]` | Detect platform, monorepo surfaces, design signals, available sensors |
207
+ | `vise inspect [path]` | Detect platform, monorepo surfaces, design signals, available sensors, and bounded scan coverage. An incomplete scan cannot initialize or produce a green check; select a concrete app surface in a large monorepo |
206
208
  | `vise plan [path] --request "..." [--summary]` | Grounded implementation plan with intake questions and docs citations; `--summary` prints a compact route/intake view |
207
209
  | `vise plan --summary "..."` | Shortcut for quick routing or discovery when the current directory is the repo |
208
210
  | `vise plan-harness [path] --request "..."` | Pre-planning step: build the harness around the request |
@@ -253,7 +255,7 @@ Everything in this group is local and advisory: no uploads, no `vise check` exit
253
255
  | Command | Purpose |
254
256
  |---|---|
255
257
  | `vise check [path]` | Re-validate against the recorded contract: `green`, `needs-attestation`, `deterministic-failures`, `blocked`, `contract-drift`, `completeness-gap`, `selected-capability-failures`, `no-platform` (no SDK platform detected — reported as a failure, not a vacuous green), or `runtime-proof-waived` |
256
- | `vise check [path] --ci` | Read-only variant that exits non-zero unless green (for CI) |
258
+ | `vise check [path] --ci` | Read-only release gate for the active contract **and every recorded engagement**. Active non-green keeps its normal exit code; active green with completed-work drift exits `11` |
257
259
  | `vise check [path] --allow-proof-waiver` | Accept an honest `runtime-proof-waived` result (from `vise smoke waive`) as passing (exit 0) instead of blocking; without it a waiver blocks CI by default |
258
260
  | `vise check [path] --new-only` | **Brownfield gate.** With a recorded baseline, gate only on rule findings introduced *since* the baseline (pre-existing ones are reported but excluded). The outcome's completeness checklist and selected optional capabilities are **never** excluded — they are the engagement's deliverables and gate like a default check. Default `check` always gates on everything |
259
261
  | `vise check [path] --engagement <outcome>` | **Re-verdict one recorded engagement** (from `sp-vise/engagements/`) against the current ruleset and code. Exit `11` on drift, `0` when it still holds; never the active engagement's codes. `ruleset_upgraded` discloses when the installed ruleset moved since it was recorded |
@@ -269,7 +271,7 @@ Everything in this group is local and advisory: no uploads, no `vise check` exit
269
271
 
270
272
  | Command | Purpose |
271
273
  |---|---|
272
- | `vise run-sensors [path] [--dry-run]` | Run detected project scripts (npm, Gradle, Flutter, lint, typecheck, SDK smokes); `--dry-run` lists without executing |
274
+ | `vise run-sensors [path] [--dry-run] [--include "<sensor name>"]` | Run detected project scripts (npm, Gradle, Flutter, lint, typecheck, SDK smokes); `--dry-run` lists without executing. Every explicit include must match a detected sensor or the command fails; output is bounded and timed-out descendants are terminated |
273
275
  | `vise smoke [path] --log <file>` | Assess a captured mount-smoke log into a pass/fail verdict + record evidence. `expect: "populated"` requires `loaded count=N` with `N > 0`; this catches session-establish and empty-data gaps that static checks cannot see |
274
276
  | `vise smoke waive [path] --reason "..." [--mode declined\|deviceless]` | Record an auditable runtime-proof waiver when proof is declined or no device/emulator is available, instead of faking evidence. `vise check` then reports `runtime-proof-waived` (exit 9; accept with `--allow-proof-waiver`) rather than a red-forever live surface |
275
277
  | `vise install-skill --target <host>` | Install the bundled skill into a coding host (see [Quick Start](#quick-start)) |
@@ -321,7 +323,7 @@ jobs:
321
323
  - run: vise check . --ci
322
324
  ```
323
325
 
324
- `vise check --ci` is read-only — it never updates `sp-vise/` — and its JSON output includes a structured `ci` block for pipeline logs.
326
+ `vise check --ci` is read-only — it never updates `sp-vise/` — and its JSON output includes a structured `ci` block for pipeline logs. It automatically re-verdicts every recorded engagement, so CI holds previously completed surfaces green without a second `--all-engagements` command.
325
327
 
326
328
  | Exit code | Meaning |
327
329
  |---|---|
@@ -334,7 +336,7 @@ jobs:
334
336
  | `6` | An explicitly selected optional capability failed its source sensors |
335
337
  | `8` | No SDK platform detected (`no-platform`) — reported as a failure, never a vacuous green |
336
338
  | `9` | Runtime proof waived (`runtime-proof-waived`) — accept explicitly with `--allow-proof-waiver` |
337
- | `11` | Engagement drift (`--engagement` / `--all-engagements` only): the active gate is green but a recorded engagement re-verdicts non-green — a previously finished surface no longer holds |
339
+ | `11` | Engagement drift (`--ci`, `--engagement`, or `--all-engagements`): the active gate is green but a recorded engagement re-verdicts non-green — a previously finished surface no longer holds |
338
340
 
339
341
  ## Compliance Contract
340
342
 
package/dist/server.js CHANGED
@@ -369,7 +369,7 @@ async function handleCli(args) {
369
369
  surfacePath: flagValue(args, "surface") ?? flagValue(args, "surface-path"),
370
370
  });
371
371
  const status = toolResultStatus(printed);
372
- if (status === "failed" || status === "timed-out") {
372
+ if (status === "failed" || status === "timed-out" || status === "invalid-selection" || status === "incomplete-analysis") {
373
373
  process.exitCode = 1;
374
374
  }
375
375
  return "exit";
@@ -461,12 +461,16 @@ async function handleCli(args) {
461
461
  if (sub === "validate") {
462
462
  assertOnlyKnownFlags(subArgs, ["block", "surface", "surface-path", "registry", "format"], "blocks validate");
463
463
  assertJsonFormat(subArgs, "blocks validate");
464
- console.log(JSON.stringify(await validateBlockInstall({
464
+ const validation = await validateBlockInstall({
465
465
  repoPath: positionalRepoPath(subArgs),
466
466
  blockId: flagValue(subArgs, "block"),
467
467
  registryPath: requiredFlagValue(subArgs, "registry", "blocks validate requires --registry."),
468
468
  surfacePath: flagValue(subArgs, "surface") ?? flagValue(subArgs, "surface-path"),
469
- }), null, 2));
469
+ });
470
+ console.log(JSON.stringify(validation, null, 2));
471
+ if (validation.validationStatus === "failed" || validation.status === "needs-review") {
472
+ process.exitCode = 1;
473
+ }
470
474
  return "exit";
471
475
  }
472
476
  console.error(`Unknown blocks subcommand: ${sub ?? "(none)"}. Expected "list", "plan", "add", or "validate".`);
@@ -526,17 +530,15 @@ async function handleCli(args) {
526
530
  process.exitCode = typeof reVerdict.exitCode === "number" ? reVerdict.exitCode : 0;
527
531
  return "exit";
528
532
  }
529
- if (hasFlag(args, "all-engagements")) {
530
- if (hasFlag(args, "ci")) {
531
- throw new Error("--all-engagements cannot combine with --ci (the CI shape covers the active engagement only).");
532
- }
533
+ const ciMode = hasFlag(args, "ci");
534
+ if (hasFlag(args, "all-engagements") || ciMode) {
533
535
  const combined = await checkAllEngagements(positionalRepoPath(args.slice(1)), { newOnly: hasFlag(args, "new-only"), allowProofWaiver: hasFlag(args, "allow-proof-waiver") });
534
- emitResult("check", combined, args);
536
+ emitResult("check", ciMode ? ciCheckResult(combined) : combined, args);
535
537
  process.exitCode = typeof combined.exitCode === "number" ? combined.exitCode : 0;
536
538
  return "exit";
537
539
  }
538
540
  const result = await checkCompliance(positionalRepoPath(args.slice(1)), { newOnly: hasFlag(args, "new-only"), allowProofWaiver: hasFlag(args, "allow-proof-waiver") });
539
- emitResult("check", hasFlag(args, "ci") ? ciCheckResult(result) : result, args);
541
+ emitResult("check", result, args);
540
542
  process.exitCode = result.exitCode;
541
543
  return "exit";
542
544
  }
@@ -984,7 +986,9 @@ Usage:
984
986
  vise blocks validate <repoPath> [--block comments] --registry <path> --format json
985
987
 
986
988
  Safety:
987
- Dry-run is the default. Apply mode only edits package manifests, explicit source anchors, and sp-vise/blocks.json.`;
989
+ Dry-run is the default. Apply mode only edits package manifests, explicit source anchors, and sp-vise/blocks.json.
990
+ Apply records pending-mount. A block capability counts only after the imported symbol is mounted/used;
991
+ run blocks validate after mounting it to persist verified evidence.`;
988
992
  }
989
993
  if (command === "init") {
990
994
  return `${packageName} init
@@ -1001,6 +1005,7 @@ Usage:
1001
1005
  return `${packageName} check
1002
1006
 
1003
1007
  Check the current source and recorded attestations against the compliance contract. Read-only.
1008
+ --ci also re-verdicts every recorded engagement and exits 11 if completed work drifted.
1004
1009
 
1005
1010
  Usage:
1006
1011
  vise check [repoPath] [--ci] [--format human]
@@ -1590,7 +1595,12 @@ async function activeBlueprint(repoRoot, request, plan) {
1590
1595
  const planSequence = socialWorkplanSequence(plan);
1591
1596
  const flowFallback = await socialWorkplanSequenceFromFlow(repoRoot, request);
1592
1597
  const canonicalRequest = flowFallback?.request ?? request;
1593
- const full = planSequence.length ? planSequence : flowFallback?.sequence ?? [];
1598
+ const isCanonicalRequest = normalizeWorkflowRequest(request) === normalizeWorkflowRequest(canonicalRequest);
1599
+ const full = flowFallback && !isCanonicalRequest
1600
+ ? flowFallback.sequence
1601
+ : planSequence.length
1602
+ ? planSequence
1603
+ : flowFallback?.sequence ?? [];
1594
1604
  const omitted = await readOmittedSurfaces(repoRoot, canonicalRequest);
1595
1605
  const omittedIds = new Set(omitted.map((item) => item.id));
1596
1606
  return { sequence: full.filter((surface) => !omittedIds.has(surface.id)), omitted, canonicalRequest };
@@ -2588,8 +2598,11 @@ function ciCheckResult(result) {
2588
2598
  "needs-attestation",
2589
2599
  "completeness-gap",
2590
2600
  "selected-capability-failures",
2601
+ "engagement-drift",
2591
2602
  ],
2592
- message: result.exitCode === 0 ? "Compliance green for CI." : `Compliance failed for CI with status: ${result.status}.`,
2603
+ message: result.exitCode === 0
2604
+ ? "Active and recorded engagement compliance is green for CI."
2605
+ : `Compliance failed for CI with status: ${result.status}.`,
2593
2606
  },
2594
2607
  };
2595
2608
  }
@@ -2791,7 +2804,7 @@ function doctorResult() {
2791
2804
  packageRoot,
2792
2805
  status: nodeMajor >= 18 ? "ok" : "unsupported-node",
2793
2806
  node: process.versions.node,
2794
- requiredNodeMajor: ">=18",
2807
+ requiredNodeMajor: ">=20",
2795
2808
  docsSource: process.env.SOCIAL_PLUS_DOCS_ROOT ? "local" : "hosted",
2796
2809
  docsRoot: process.env.SOCIAL_PLUS_DOCS_ROOT,
2797
2810
  docsBaseUrl: process.env.SOCIAL_PLUS_DOCS_BASE_URL ?? "https://learn.social.plus",
@@ -5,7 +5,7 @@ import { packageVersion } from "../version.js";
5
5
  import { inspectProject } from "./project.js";
6
6
  import { detectCommandSensors } from "./harness.js";
7
7
  import { readDesignContract } from "./design.js";
8
- const blocksSidecarSchemaVersion = "2026-06-10.vise-blocks-sidecar.v1";
8
+ const blocksSidecarSchemaVersion = "2026-07-10.vise-blocks-sidecar.v2";
9
9
  const registryPlatformByVisePlatform = {
10
10
  typescript: "react",
11
11
  "react-native": "react-native",
@@ -74,11 +74,21 @@ export async function validateBlockInstall(options) {
74
74
  if (installed.length === 0) {
75
75
  findings.push({
76
76
  ruleId: "blocks.sidecar.installed",
77
- severity: "warning",
77
+ severity: "error",
78
78
  message: "No installed block sidecar entries exist.",
79
79
  recommendation: "Run `vise blocks add <repoPath> --block <id> --registry <path> --apply` after reviewing the dry-run plan.",
80
80
  });
81
81
  }
82
+ for (const entry of installed) {
83
+ if (!(await installedEntryLocallyValid(repoPath, entry))) {
84
+ findings.push({
85
+ ruleId: "blocks.install.verified",
86
+ severity: "error",
87
+ message: `Installed block ${entry.blockId} is not currently verified: its dependency, touched files, or live mount evidence is missing.`,
88
+ recommendation: `Run \`vise blocks validate <repoPath> --block ${entry.blockId} --registry <path>\` and resolve every reported error.`,
89
+ });
90
+ }
91
+ }
82
92
  return {
83
93
  status: findings.some((finding) => finding.severity === "error") ? "needs-review" : "ready",
84
94
  mode: "validate",
@@ -96,7 +106,7 @@ export async function validateBlockInstall(options) {
96
106
  if (installed.length === 0) {
97
107
  findings.push({
98
108
  ruleId: "blocks.sidecar.installed",
99
- severity: "warning",
109
+ severity: "error",
100
110
  message: options.blockId ? `No sidecar entry exists for block ${options.blockId}.` : "No installed block sidecar entries exist.",
101
111
  recommendation: "Run `vise blocks add <repoPath> --block <id> --registry <path> --apply` after reviewing the dry-run plan.",
102
112
  });
@@ -119,12 +129,30 @@ export async function validateBlockInstall(options) {
119
129
  });
120
130
  }
121
131
  }
132
+ const mountEvidence = await findBlockMountEvidence(repoPath, {
133
+ platform: plan.registryPlatform,
134
+ publicEntrypoint: plan.package.publicEntrypoint,
135
+ importName: plan.package.importName,
136
+ mountFiles: plan.targetFiles.map((targetFile) => targetFile.path),
137
+ });
138
+ if (!mountEvidence) {
139
+ findings.push({
140
+ ruleId: "blocks.mount.present",
141
+ severity: "error",
142
+ message: `Block ${plan.block.id} is installed but ${plan.package.importName} is not mounted in the target source.`,
143
+ recommendation: `Render or invoke ${plan.package.importName} in ${plan.targetFiles.map((targetFile) => targetFile.path).join(", ")}, then re-run block validation. An import alone does not deliver the block capability.`,
144
+ });
145
+ }
122
146
  const hasError = findings.some((finding) => finding.severity === "error");
147
+ await updateBlockValidationState(repoPath, plan.block.id, hasError ? "invalid" : "verified", mountEvidence);
148
+ const updatedSidecar = await readSidecar(repoPath);
149
+ const updatedInstalled = (updatedSidecar?.installed ?? []).filter((entry) => entry.blockId === options.blockId);
123
150
  return {
124
151
  ...plan,
125
152
  status: hasError ? "needs-review" : plan.status,
126
153
  validationStatus: hasError ? "failed" : "passed",
127
- sidecarInstalled: installed,
154
+ mountEvidence,
155
+ sidecarInstalled: updatedInstalled,
128
156
  findings,
129
157
  };
130
158
  }
@@ -319,10 +347,13 @@ async function writeBlocksSidecar(repoPath, plan, packageSource, filesTouched) {
319
347
  packageSource,
320
348
  dependencyName: plan.package.dependencyName,
321
349
  providesCapabilities: plan.providesCapabilities,
350
+ publicEntrypoint: plan.package.publicEntrypoint,
351
+ importName: plan.package.importName,
352
+ mountFiles: plan.targetFiles.map((targetFile) => targetFile.path),
322
353
  filesTouched,
323
354
  designContractDigest: designContract?.digest,
324
355
  sdkFactsVersion: plan.block.version,
325
- validationStatus: "installed",
356
+ validationStatus: "pending-mount",
326
357
  };
327
358
  sidecar.schemaVersion = blocksSidecarSchemaVersion;
328
359
  sidecar.viseVersion = packageVersion;
@@ -376,7 +407,46 @@ async function installedEntryLocallyValid(repoRoot, entry) {
376
407
  return false;
377
408
  }
378
409
  }
379
- return true;
410
+ return Boolean(await findBlockMountEvidence(repoRoot, entry));
411
+ }
412
+ async function findBlockMountEvidence(repoRoot, entry) {
413
+ if (!entry.publicEntrypoint || !entry.importName || !Array.isArray(entry.mountFiles) || entry.mountFiles.length === 0) {
414
+ return undefined;
415
+ }
416
+ for (const relativeFile of entry.mountFiles) {
417
+ if (typeof relativeFile !== "string" || relativeFile.trim() === "") {
418
+ continue;
419
+ }
420
+ const source = await readFile(path.join(repoRoot, relativeFile), "utf8").catch(() => "");
421
+ if (!source.includes(entry.publicEntrypoint) || !blockUsagePresent(source, entry.platform, entry.importName)) {
422
+ continue;
423
+ }
424
+ return { file: relativeFile, kind: "source-usage", importName: entry.importName };
425
+ }
426
+ return undefined;
427
+ }
428
+ function blockUsagePresent(source, platform, importName) {
429
+ const name = escapeRegExp(importName);
430
+ if (platform === "flutter") {
431
+ return new RegExp(`\\b${name}\\s*\\(`).test(source);
432
+ }
433
+ return [
434
+ new RegExp(`<\\s*${name}(?:\\s|/|>)`),
435
+ new RegExp(`(?:React\\.)?createElement\\s*\\(\\s*${name}\\b`),
436
+ new RegExp(`\\b${name}\\s*\\(`),
437
+ ].some((pattern) => pattern.test(source));
438
+ }
439
+ async function updateBlockValidationState(repoRoot, blockId, validationStatus, mountEvidence) {
440
+ const sidecar = await readSidecar(repoRoot);
441
+ const entry = sidecar?.installed.find((item) => item.blockId === blockId);
442
+ if (!sidecar || !entry) {
443
+ return;
444
+ }
445
+ entry.validationStatus = validationStatus;
446
+ entry.mountEvidence = mountEvidence;
447
+ sidecar.viseVersion = packageVersion;
448
+ sidecar.generatedAt = new Date().toISOString();
449
+ await writeFile(path.join(repoRoot, "sp-vise", "blocks.json"), `${JSON.stringify(sidecar, null, 2)}\n`, "utf8");
380
450
  }
381
451
  async function blockDependencyDeclared(repoRoot, entry) {
382
452
  const dependencyName = entry.dependencyName;
@@ -433,12 +503,15 @@ function insertBlockSnippet(source, plan) {
433
503
  return source;
434
504
  }
435
505
  function addPubspecDependency(source, dependencyName, value) {
506
+ const dependencyLines = value.startsWith("file:")
507
+ ? [` ${dependencyName}:`, ` path: ${value.replace(/^file:/, "")}`]
508
+ : [` ${dependencyName}: ${value}`];
436
509
  if (!source.includes("dependencies:")) {
437
- return `${source.trimEnd()}\n\ndependencies:\n ${dependencyName}:\n path: ${value.replace(/^file:/, "")}\n`;
510
+ return `${source.trimEnd()}\n\ndependencies:\n${dependencyLines.join("\n")}\n`;
438
511
  }
439
512
  const lines = source.split(/\r?\n/);
440
513
  const insertIndex = lines.findIndex((line) => line.trim() === "dependencies:") + 1;
441
- lines.splice(insertIndex, 0, ` ${dependencyName}:`, ` path: ${value.replace(/^file:/, "")}`);
514
+ lines.splice(insertIndex, 0, ...dependencyLines);
442
515
  return `${lines.join("\n").trimEnd()}\n`;
443
516
  }
444
517
  async function readPackageJson(filePath) {
@@ -16,7 +16,7 @@ import { packageVersion } from "../version.js";
16
16
  import { readFlowState } from "../flow.js";
17
17
  import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
18
18
  import { installedBlockProvidedCapabilities } from "./blocks.js";
19
- import { inspectProject, validateSetup } from "./project.js";
19
+ import { inspectProject, validateSetupWithCoverage, } from "./project.js";
20
20
  import { readCreativeSelection } from "./creative.js";
21
21
  import { readRuntimeProofWaiver, hasExecutableSmokeMarker } from "./smoke.js";
22
22
  import { assessUxHarness, buildExperienceReport, buildUxHarness, readUxHarness, } from "./uxHarness.js";
@@ -326,6 +326,18 @@ function designReviewFor(repoRoot, contract, answers) {
326
326
  export async function initCompliance(repoPath, request, surfacePath, answers = {}, options = {}) {
327
327
  const repoRoot = path.resolve(repoPath);
328
328
  const inspection = await inspectProject(repoRoot, surfacePath);
329
+ if (!inspection.scanCoverage.complete) {
330
+ const truncated = inspection.scanCoverage.scans.filter((scan) => scan.truncated);
331
+ return {
332
+ status: "needs-clarification",
333
+ exitCode: 7,
334
+ reason: `Vise could not inspect the complete project inventory: ${truncated
335
+ .map((scan) => `${scan.scope} reached its ${scan.limit}-file limit under ${scan.root}`)
336
+ .join("; ")}.`,
337
+ instruction: "Select the specific app/workspace surface before initializing a compliance contract; an incomplete platform inventory cannot be baked into a greenable sidecar.",
338
+ nextStep: "Re-run `vise init` with --surface <app-path> (or --surface-path <app-path>) after narrowing the project.",
339
+ };
340
+ }
329
341
  const outcome = resolveOutcome(request, answers);
330
342
  const platform = preferredPlatform(inspection.platforms);
331
343
  const capabilityAvailability = await platformCapabilityAvailability(outcome, platform);
@@ -931,8 +943,23 @@ export async function checkCompliance(repoPath, options = {}) {
931
943
  }
932
944
  }
933
945
  const { canonicalPlatform } = await import("./sdkFacts.js");
934
- const allFindings = await Promise.all(platforms.map((p) => validateSetup(inspection.effectiveRoot, p)));
935
- const findings = allFindings.flat();
946
+ const validations = await Promise.all(platforms.map((p) => validateSetupWithCoverage(inspection.effectiveRoot, p)));
947
+ const findings = validations.flatMap((validation) => validation.findings);
948
+ const scanCoverage = {
949
+ complete: inspection.scanCoverage.complete && validations.every((validation) => validation.scanCoverage.complete),
950
+ scans: [...inspection.scanCoverage.scans, ...validations.flatMap((validation) => validation.scanCoverage.scans)],
951
+ };
952
+ if (!inspection.scanCoverage.complete && !findings.some((finding) => finding.ruleId === "project.scan.complete")) {
953
+ const truncated = inspection.scanCoverage.scans.filter((scan) => scan.truncated);
954
+ findings.push({
955
+ ruleId: "project.scan.complete",
956
+ severity: "error",
957
+ message: `Vise could not inspect the complete project inventory: ${truncated
958
+ .map((scan) => `${scan.scope} reached its ${scan.limit}-file limit under ${scan.root}`)
959
+ .join("; ")}.`,
960
+ recommendation: "Point Vise at the specific app with --surface/--surface-path and re-run init/check.",
961
+ });
962
+ }
936
963
  const findingsById = new Map();
937
964
  const findingsByIdAll = new Map();
938
965
  for (const finding of findings) {
@@ -944,7 +971,7 @@ export async function checkCompliance(repoPath, options = {}) {
944
971
  }
945
972
  }
946
973
  const attestations = await readAttestations(repoRoot);
947
- const attestationHygiene = await readAttestationHygiene(repoRoot, compliance);
974
+ const attestationHygiene = await readAttestationHygiene(repoRoot, inspection.effectiveRoot, compliance);
948
975
  const results = [];
949
976
  for (const ref of compliance.rules) {
950
977
  const rule = rules.get(ref.rule_id);
@@ -1138,6 +1165,18 @@ export async function checkCompliance(repoPath, options = {}) {
1138
1165
  ...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
1139
1166
  });
1140
1167
  }
1168
+ const incompleteScanFinding = findings.find((finding) => finding.ruleId === "project.scan.complete");
1169
+ if (incompleteScanFinding) {
1170
+ results.push({
1171
+ ruleId: incompleteScanFinding.ruleId,
1172
+ title: "Complete source scan",
1173
+ severity: "error",
1174
+ status: "deterministic-fail",
1175
+ reason: incompleteScanFinding.message,
1176
+ finding: incompleteScanFinding,
1177
+ recommendation: incompleteScanFinding.recommendation,
1178
+ });
1179
+ }
1141
1180
  const driftStale = await surfaceDriftStale(compliance);
1142
1181
  if (driftStale.size > 0) {
1143
1182
  for (const result of results) {
@@ -1232,7 +1271,7 @@ export async function checkCompliance(repoPath, options = {}) {
1232
1271
  const { status, exitCode } = deriveGate({
1233
1272
  hasNoPlatform: detectedPlatforms.length === 0,
1234
1273
  hasBlocked: gatedResults.some((result) => result.status === "blocked"),
1235
- hasDeterministicFailure: gatedResults.some((result) => result.status === "deterministic-fail"),
1274
+ hasDeterministicFailure: !scanCoverage.complete || gatedResults.some((result) => result.status === "deterministic-fail"),
1236
1275
  needsAttestation: gatedResults.some((result) => result.status === "attestation-needed" || result.status === "stale"),
1237
1276
  hasCompletenessGap,
1238
1277
  hasSelectedOptionalFailures,
@@ -1264,6 +1303,7 @@ export async function checkCompliance(repoPath, options = {}) {
1264
1303
  surfacePath: compliance.surface?.path,
1265
1304
  summary,
1266
1305
  rules: results,
1306
+ scanCoverage,
1267
1307
  ...(evidenceBasis ? { evidence_basis: evidenceBasis } : {}),
1268
1308
  ...(evidenceBasisNote ? { evidence_basis_note: evidenceBasisNote } : {}),
1269
1309
  ...(completeness && (completeness.missing.length > 0 || completeness.optedOut.length > 0 || completeness.present.length > 0) ? { completeness } : {}),
@@ -2327,7 +2367,8 @@ async function liveSensorContradiction(repoRoot, compliance, rule) {
2327
2367
  if (platforms.length === 0) {
2328
2368
  return { violatorFiles: [] };
2329
2369
  }
2330
- const allFindings = await Promise.all(platforms.map((platform) => validateSetup(inspection.effectiveRoot, platform)));
2370
+ const validations = await Promise.all(platforms.map((platform) => validateSetupWithCoverage(inspection.effectiveRoot, platform)));
2371
+ const allFindings = validations.map((validation) => validation.findings);
2331
2372
  const findingsById = new Map();
2332
2373
  const findingsByIdAll = new Map();
2333
2374
  for (const finding of allFindings.flat()) {
@@ -2340,6 +2381,19 @@ async function liveSensorContradiction(repoRoot, compliance, rule) {
2340
2381
  }
2341
2382
  const finding = deterministicFinding(rule, findingsById);
2342
2383
  if (!finding) {
2384
+ const incomplete = allFindings.flat().find((item) => item.ruleId === "project.scan.complete");
2385
+ if (incomplete) {
2386
+ return {
2387
+ warning: {
2388
+ message: "The live deterministic sensor could not inspect the complete source surface. This attestation overrides an incomplete analysis; narrow the surface and re-check before relying on it.",
2389
+ sensor_finding: {
2390
+ ruleId: incomplete.ruleId,
2391
+ recommendation: incomplete.recommendation,
2392
+ },
2393
+ },
2394
+ violatorFiles: [],
2395
+ };
2396
+ }
2343
2397
  return { violatorFiles: [] };
2344
2398
  }
2345
2399
  const violatorFiles = await ruleViolatorFiles(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll);
@@ -2760,9 +2814,24 @@ async function readAttestations(repoRoot) {
2760
2814
  }
2761
2815
  return result;
2762
2816
  }
2763
- async function readAttestationHygiene(repoRoot, compliance) {
2817
+ async function recordedEngagementRuleIds(repoRoot) {
2818
+ const ruleIds = new Set();
2819
+ const entries = await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []);
2820
+ for (const entry of entries) {
2821
+ if (!entry.isDirectory()) {
2822
+ continue;
2823
+ }
2824
+ const contract = await readJsonIfExists(path.join(engagementsDir(repoRoot), entry.name, "contract.json"));
2825
+ for (const rule of contract?.rules ?? []) {
2826
+ ruleIds.add(rule.rule_id);
2827
+ }
2828
+ }
2829
+ return ruleIds;
2830
+ }
2831
+ async function readAttestationHygiene(repoRoot, sourceRoot, compliance) {
2764
2832
  const dir = attestationsDir(repoRoot);
2765
2833
  const activeRuleIds = new Set(compliance.rules.map((rule) => rule.rule_id));
2834
+ const recordedRuleIds = await recordedEngagementRuleIds(repoRoot);
2766
2835
  const ignored = [];
2767
2836
  const invalid = [];
2768
2837
  let entries = [];
@@ -2783,10 +2852,24 @@ async function readAttestationHygiene(repoRoot, compliance) {
2783
2852
  continue;
2784
2853
  }
2785
2854
  if (!activeRuleIds.has(candidate.attestation.rule_id)) {
2855
+ const sourceFingerprintStatus = await checkSourceFingerprints(repoRoot, sourceRoot, candidate.attestation.source_fingerprints ?? []);
2856
+ const staleFingerprints = sourceFingerprintStatus.filter((item) => item.status !== "match");
2857
+ if (staleFingerprints.length > 0) {
2858
+ ignored.push({
2859
+ file: rel,
2860
+ rule_id: candidate.attestation.rule_id,
2861
+ reason: "Attestation is outside the active contract and its recorded source fingerprints changed or disappeared. Refresh or remove it before relying on the evidence trail.",
2862
+ source_fingerprint_status: sourceFingerprintStatus,
2863
+ });
2864
+ continue;
2865
+ }
2866
+ if (recordedRuleIds.has(candidate.attestation.rule_id)) {
2867
+ continue;
2868
+ }
2786
2869
  ignored.push({
2787
2870
  file: rel,
2788
2871
  rule_id: candidate.attestation.rule_id,
2789
- reason: "Attestation rule_id is not in the current compliance contract. It may be stale, off-platform, or from a previous sidecar scope; it is ignored by vise check.",
2872
+ reason: "Attestation rule_id is not in the active or recorded engagement contracts. It may be stale or off-platform and is ignored by vise check.",
2790
2873
  });
2791
2874
  }
2792
2875
  }
@@ -2797,7 +2880,7 @@ async function readAttestationHygiene(repoRoot, compliance) {
2797
2880
  status: "needs-review",
2798
2881
  ignored,
2799
2882
  invalid,
2800
- nextStep: "Review ignored/invalid attestation files before using the evidence trail for handoff. Remove stale files only when you are sure they are unrelated, or re-run vise init/check/sync for the intended scope.",
2883
+ nextStep: "Review fingerprint-stale, orphaned, or invalid attestation files before handoff. Matching evidence retained by a recorded engagement is checked through the engagement ledger and is not reported as stale.",
2801
2884
  };
2802
2885
  }
2803
2886
  async function readAttestationCandidate(filePath) {
@@ -218,7 +218,7 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
218
218
  ...solutionPathImplementationSteps(solutionPath),
219
219
  ...uikitCustomizationImplementationSteps(uikitCustomization),
220
220
  ...crossPlatformHandoffSteps,
221
- ...definition.implementationSteps(ctx),
221
+ ...outcomeImplementationStepsForSolutionPath(solutionPath, definition.implementationSteps(ctx)),
222
222
  ],
223
223
  validation: ["validate_setup", "run_sensors", ...definition.validation(platform)],
224
224
  ...(clarify ? { clarify } : {}),
@@ -442,18 +442,30 @@ function solutionPathDecisions(solutionPath, answers) {
442
442
  ];
443
443
  }
444
444
  function solutionPathImplementationSteps(solutionPath) {
445
- if (!solutionPath.decision.requiredBeforeHandRolledUi) {
445
+ const unresolved = solutionPath.decision.requiredBeforeHandRolledUi;
446
+ if (solutionPath.recommendation === "sdk" && !unresolved) {
446
447
  return [];
447
448
  }
449
+ const step = solutionPath.recommendation === "uikit"
450
+ ? unresolved
451
+ ? "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."
452
+ : "Implement the explicitly selected social.plus UIKit path as the primary artifact: follow UIKit installation, authentication, provider/page, behavior, and customization guidance. Direct-SDK outcome steps are intentionally omitted for this locked UIKit route; do not hand-roll the standard social surface from SDK primitives."
453
+ : solutionPath.recommendation === "hybrid" && !unresolved
454
+ ? "Implement the explicitly selected hybrid path: use UIKit for standard social surfaces, and apply the direct-SDK outcome steps only to the differentiated app-specific behavior that UIKit does not cover. Do not rebuild UIKit-covered surfaces from SDK primitives."
455
+ : "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.";
448
456
  return [
449
457
  {
450
- step: solutionPath.recommendation === "uikit"
451
- ? "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."
452
- : "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.",
458
+ step,
453
459
  evidence: ["solutionPath", ...solutionPath.evidence],
454
460
  },
455
461
  ];
456
462
  }
463
+ function outcomeImplementationStepsForSolutionPath(solutionPath, outcomeSteps) {
464
+ if (solutionPath.recommendation === "uikit" && !solutionPath.decision.requiredBeforeHandRolledUi) {
465
+ return [];
466
+ }
467
+ return outcomeSteps;
468
+ }
457
469
  function solutionPathImplementationRules(solutionPath) {
458
470
  if (solutionPath.recommendation === "sdk" && !solutionPath.decision.requiredBeforeHandRolledUi) {
459
471
  return [];
@@ -719,7 +731,11 @@ const SOCIAL_SURFACE_SEQUENCE = [
719
731
  id: "community",
720
732
  outcome: "add-community",
721
733
  label: "Community management",
722
- aliases: [/\b(create|manage|join|leave|membership|members?|roles?|invitations?|categories?)\s+(?:a\s+|the\s+)?communit/i, /\bcommunity\s+(creation|management|members?|roles?|invitations?|categories|settings|moderation)\b/i],
734
+ aliases: [
735
+ /\bcommunities\b/i,
736
+ /\b(create|manage|join|leave|membership|members?|roles?|invitations?|categories?)\s+(?:a\s+|the\s+)?communit/i,
737
+ /\bcommunity\s+(creation|management|members?|roles?|invitations?|categories|settings|moderation)\b/i,
738
+ ],
723
739
  },
724
740
  {
725
741
  id: "notifications",
@@ -743,6 +759,13 @@ async function socialWorkplanFor(args) {
743
759
  const surface = SOCIAL_SURFACE_SEQUENCE.find((item) => item.id === hint.id);
744
760
  return surface ? [{ surface, creativeHint: hint }] : [];
745
761
  });
762
+ const selectedIds = new Set(selected.map((entry) => entry.surface.id));
763
+ for (const surface of matched) {
764
+ if (!selectedIds.has(surface.id)) {
765
+ selected.push({ surface });
766
+ selectedIds.add(surface.id);
767
+ }
768
+ }
746
769
  if (selected.length <= 1) {
747
770
  return undefined;
748
771
  }
@@ -39,7 +39,7 @@ export async function inspectProject(repoPath, surfacePath) {
39
39
  const surfaces = await detectProjectSurfaces(repoRoot);
40
40
  const effectiveRoot = resolveSurfaceRoot(repoRoot, surfacePath);
41
41
  const selectedSurface = surfacePath ? await surfaceForPath(repoRoot, effectiveRoot) : surfaceMatchingRoot(surfaces, repoRoot);
42
- const { platforms, signals, designSignals } = await inspectRoot(effectiveRoot);
42
+ const { platforms, signals, designSignals, scanCoverage } = await inspectRoot(effectiveRoot);
43
43
  return {
44
44
  repoRoot,
45
45
  effectiveRoot,
@@ -48,6 +48,7 @@ export async function inspectProject(repoPath, surfacePath) {
48
48
  signals,
49
49
  designSignals,
50
50
  surfaces,
51
+ scanCoverage,
51
52
  };
52
53
  }
53
54
  function resolveSurfaceRoot(repoRoot, surfacePath) {
@@ -63,6 +64,7 @@ function resolveSurfaceRoot(repoRoot, surfacePath) {
63
64
  }
64
65
  async function inspectRoot(root) {
65
66
  const signals = [];
67
+ const scanContext = { validationRoot: root, scans: [] };
66
68
  const candidates = [
67
69
  ["android", "settings.gradle", "Gradle project settings"],
68
70
  ["android", "settings.gradle.kts", "Gradle Kotlin project settings"],
@@ -100,7 +102,7 @@ async function inspectRoot(root) {
100
102
  }
101
103
  const hasJsSignal = signals.some((signal) => signal.platform === "typescript" || signal.platform === "react-native");
102
104
  if (!hasJsSignal) {
103
- const sourceSignal = await detectSdkSignalFromSource(root);
105
+ const sourceSignal = await detectSdkSignalFromSource(root, scanContext);
104
106
  if (sourceSignal) {
105
107
  signals.push(sourceSignal);
106
108
  }
@@ -113,10 +115,18 @@ async function inspectRoot(root) {
113
115
  : hasAndroid
114
116
  ? rawPlatforms.filter((p) => p !== "typescript")
115
117
  : rawPlatforms;
116
- return { platforms, signals, designSignals: await detectDesignSignals(root) };
118
+ return {
119
+ platforms,
120
+ signals,
121
+ designSignals: await detectDesignSignals(root),
122
+ scanCoverage: {
123
+ complete: scanContext.scans.every((scan) => !scan.truncated),
124
+ scans: scanContext.scans,
125
+ },
126
+ };
117
127
  }
118
- async function detectSdkSignalFromSource(root) {
119
- const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".htm"], 500);
128
+ async function detectSdkSignalFromSource(root, scanContext) {
129
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".htm"], 500, scanContext, "platform-detection.javascript-source");
120
130
  if (sourceFiles.length === 0) {
121
131
  return null;
122
132
  }
@@ -269,7 +279,25 @@ async function installedBlockEntryStillValid(root, entry) {
269
279
  return false;
270
280
  }
271
281
  }
272
- return true;
282
+ const publicEntrypoint = typeof entry.publicEntrypoint === "string" ? entry.publicEntrypoint : "";
283
+ const importName = typeof entry.importName === "string" ? entry.importName : "";
284
+ const mountFiles = Array.isArray(entry.mountFiles) ? entry.mountFiles : [];
285
+ if (!publicEntrypoint || !importName || mountFiles.length === 0) {
286
+ return false;
287
+ }
288
+ const usagePattern = platform === "flutter"
289
+ ? new RegExp(`\\b${escapeRegExp(importName)}\\s*\\(`)
290
+ : new RegExp(`<\\s*${escapeRegExp(importName)}(?:\\s|/|>)|(?:React\\.)?createElement\\s*\\(\\s*${escapeRegExp(importName)}\\b|\\b${escapeRegExp(importName)}\\s*\\(`);
291
+ for (const mountFile of mountFiles) {
292
+ if (typeof mountFile !== "string" || mountFile.trim() === "") {
293
+ continue;
294
+ }
295
+ const source = await readIfExists(path.join(root, mountFile));
296
+ if (source.includes(publicEntrypoint) && usagePattern.test(source)) {
297
+ return true;
298
+ }
299
+ }
300
+ return false;
273
301
  }
274
302
  async function sidecarOutcomeInScope(root, outcome) {
275
303
  const dir = sidecarDir(root);
@@ -354,29 +382,56 @@ export const validateSetupTool = {
354
382
  const repoPath = stringField(args, "repoPath");
355
383
  const inspection = await inspectProject(repoPath, optionalStringField(args, "surfacePath"));
356
384
  const platform = optionalStringField(args, "platform") ?? inspection.platforms[0] ?? "unknown";
357
- const findings = await validateSetup(inspection.effectiveRoot, platform);
385
+ const validation = await validateSetupWithCoverage(inspection.effectiveRoot, platform);
386
+ const platformWasExplicit = optionalStringField(args, "platform") !== undefined;
387
+ const scanCoverage = platformWasExplicit
388
+ ? validation.scanCoverage
389
+ : {
390
+ complete: inspection.scanCoverage.complete && validation.scanCoverage.complete,
391
+ scans: [...inspection.scanCoverage.scans, ...validation.scanCoverage.scans],
392
+ };
393
+ const findings = [...validation.findings];
394
+ if (!platformWasExplicit && !inspection.scanCoverage.complete && !findings.some((finding) => finding.ruleId === "project.scan.complete")) {
395
+ findings.push(incompleteInspectionFinding(inspection.scanCoverage));
396
+ }
358
397
  return textResult({
359
398
  platform,
360
399
  surfacePath: inspection.selectedSurface?.path,
361
400
  status: statusForFindings(findings),
362
401
  findings,
402
+ scanCoverage,
363
403
  });
364
404
  },
365
405
  };
366
406
  export async function validateSetup(repoPath, platform) {
407
+ return (await validateSetupWithCoverage(repoPath, platform)).findings;
408
+ }
409
+ function incompleteInspectionFinding(scanCoverage) {
410
+ const truncated = scanCoverage.scans.filter((scan) => scan.truncated);
411
+ return {
412
+ ruleId: "project.scan.complete",
413
+ severity: "error",
414
+ message: `Vise could not inspect the complete project inventory: ${truncated
415
+ .map((scan) => `${scan.scope} reached its ${scan.limit}-file limit under ${scan.root}`)
416
+ .join("; ")}.`,
417
+ recommendation: "Point Vise at the specific app with --surface/--surface-path before planning, initializing, validating, or selecting sensors.",
418
+ };
419
+ }
420
+ export async function validateSetupWithCoverage(repoPath, platform) {
367
421
  const root = path.resolve(repoPath);
368
422
  const findings = [];
423
+ const scanContext = { validationRoot: root, scans: [] };
369
424
  if (platform === "android") {
370
- findings.push(...(await validateAndroid(root)));
425
+ findings.push(...(await validateAndroid(root, scanContext)));
371
426
  }
372
427
  if (platform === "flutter") {
373
- findings.push(...(await validateFlutter(root)));
428
+ findings.push(...(await validateFlutter(root, scanContext)));
374
429
  }
375
430
  if (platform === "typescript" || platform === "react-native") {
376
- findings.push(...(await validateTypeScript(root, platform)));
431
+ findings.push(...(await validateTypeScript(root, platform, scanContext)));
377
432
  }
378
433
  if (platform === "ios") {
379
- findings.push(...(await validateIos(root)));
434
+ findings.push(...(await validateIos(root, scanContext)));
380
435
  }
381
436
  if (platform === "unknown") {
382
437
  findings.push({
@@ -386,21 +441,38 @@ export async function validateSetup(repoPath, platform) {
386
441
  recommendation: "Point repoPath at the app root or provide a platform override.",
387
442
  });
388
443
  }
389
- return findings;
444
+ const truncatedScans = scanContext.scans.filter((scan) => scan.truncated);
445
+ if (truncatedScans.length > 0) {
446
+ findings.push({
447
+ ruleId: "project.scan.complete",
448
+ severity: "error",
449
+ message: `Vise could not inspect the complete source surface: ${truncatedScans
450
+ .map((scan) => `${scan.scope} reached its ${scan.limit}-file limit under ${scan.root}`)
451
+ .join("; ")}.`,
452
+ recommendation: "Point Vise at the specific app with --surface/--surface-path, remove generated or vendored source from that surface, or raise the governed scan limit before relying on absence-based validation.",
453
+ });
454
+ }
455
+ return {
456
+ findings,
457
+ scanCoverage: {
458
+ complete: truncatedScans.length === 0,
459
+ scans: scanContext.scans,
460
+ },
461
+ };
390
462
  }
391
- async function validateAndroid(root) {
463
+ async function validateAndroid(root, scanContext) {
392
464
  const findings = [];
393
465
  const manifestPath = "app/src/main/AndroidManifest.xml";
394
466
  const manifest = await readIfExists(path.join(root, manifestPath));
395
- const gradleScripts = (await findFiles(root, [".gradle", ".kts"], 200)).filter((f) => /(?:^|[\\/])(?:build|settings)\.gradle(?:\.kts)?$/.test(f));
467
+ const gradleScripts = (await findFiles(root, [".gradle", ".kts"], 200, scanContext, "android.gradle-manifests")).filter((f) => /(?:^|[\\/])(?:build|settings)\.gradle(?:\.kts)?$/.test(f));
396
468
  const buildFiles = [...new Set([
397
469
  ...(await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])),
398
470
  ...gradleScripts,
399
471
  ])];
400
- const versionCatalogFiles = (await findFiles(root, [".toml"], 50)).filter((f) => /(?:^|[\\/])gradle[\\/].*\.versions\.toml$/.test(f));
472
+ const versionCatalogFiles = (await findFiles(root, [".toml"], 50, scanContext, "android.version-catalogs")).filter((f) => /(?:^|[\\/])gradle[\\/].*\.versions\.toml$/.test(f));
401
473
  const buildContent = await readMany(buildFiles);
402
474
  const versionCatalogContent = await readMany(versionCatalogFiles);
403
- const sourceFiles = await findFiles(root, [".kt", ".java"], 300);
475
+ const sourceFiles = await findFiles(root, [".kt", ".java"], 300, scanContext, "android.source");
404
476
  const sourceContent = await readMany(sourceFiles);
405
477
  const setupFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*setup/, /AmityClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
406
478
  const coreLoginFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*login/, /AmityClient\s*\.\s*login/]);
@@ -518,11 +590,11 @@ async function validateAndroid(root) {
518
590
  findings.push(...(await validateEnvSecretHygiene(root, "android", sourceContent)));
519
591
  return findings;
520
592
  }
521
- async function validateFlutter(root) {
593
+ async function validateFlutter(root, scanContext) {
522
594
  const findings = [];
523
595
  const pubspec = await readIfExists(path.join(root, "pubspec.yaml"));
524
596
  const pubspecLock = await readIfExists(path.join(root, "pubspec.lock"));
525
- const dartFiles = await findFiles(path.join(root, "lib"), [".dart"], 300);
597
+ const dartFiles = await findFiles(path.join(root, "lib"), [".dart"], 300, scanContext, "flutter.source");
526
598
  const dartContent = await readMany(dartFiles);
527
599
  const setupFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
528
600
  const coreLoginFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*login/]);
@@ -643,10 +715,10 @@ async function validateFlutter(root) {
643
715
  findings.push(...(await validateEnvSecretHygiene(root, "flutter", dartContent)));
644
716
  return findings;
645
717
  }
646
- async function validateTypeScript(root, platform) {
718
+ async function validateTypeScript(root, platform, scanContext) {
647
719
  const findings = [];
648
720
  const packageJson = await readIfExists(path.join(root, "package.json"));
649
- const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500);
721
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500, scanContext, `${platform}.source`);
650
722
  const sourceContent = await readMany(sourceFiles);
651
723
  const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/, /Amity(?:UIKit|UiKit)Provider/]);
652
724
  const rawLoginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
@@ -2894,11 +2966,19 @@ function firstLiteralAssignment(contents, patterns, platform) {
2894
2966
  for (const [file, rawContent] of contents) {
2895
2967
  const content = commentStripped(file, platform, rawContent);
2896
2968
  for (const pattern of patterns) {
2897
- pattern.lastIndex = 0;
2898
- const match = pattern.exec(content);
2899
- if (match?.[1]) {
2969
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
2970
+ const matcher = new RegExp(pattern.source, flags);
2971
+ let match;
2972
+ while ((match = matcher.exec(content)) !== null) {
2973
+ if (!match[1]) {
2974
+ continue;
2975
+ }
2976
+ const value = match[1].trim();
2977
+ if (isAllowedPlaceholder(value)) {
2978
+ continue;
2979
+ }
2900
2980
  const name = match[0].split(/[=:]/)[0]?.trim() ?? "literal";
2901
- return { file, name, value: match[1].trim() };
2981
+ return { file, name, value };
2902
2982
  }
2903
2983
  }
2904
2984
  }
@@ -3252,7 +3332,7 @@ function iosAmitySdkIsFloating(s) {
3252
3332
  return true;
3253
3333
  return false;
3254
3334
  }
3255
- async function validateIos(root) {
3335
+ async function validateIos(root, scanContext) {
3256
3336
  const findings = [];
3257
3337
  const joinExisting = async (names) => [...(await readMany(await existingFiles(root, names))).values()].join("\n");
3258
3338
  const podText = await joinExisting(["Podfile"]);
@@ -3277,7 +3357,7 @@ async function validateIos(root) {
3277
3357
  ];
3278
3358
  const manifestCombined = [podText, pkgSwiftText, projectYmlText, pbxprojText].join("\n");
3279
3359
  const hasManifest = manifestCombined.trim().length > 0;
3280
- const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400);
3360
+ const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400, scanContext, "ios.source");
3281
3361
  const swiftContent = await readMany(swiftFiles);
3282
3362
  const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
3283
3363
  const coreLoginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
@@ -3603,10 +3683,11 @@ function inferDesignKind(relativePath) {
3603
3683
  }
3604
3684
  return "theme";
3605
3685
  }
3606
- async function findFiles(root, extensions, maxFiles) {
3686
+ async function findFiles(root, extensions, maxFiles, scanContext, scope = "source") {
3607
3687
  const found = [];
3688
+ let truncated = false;
3608
3689
  async function walk(directory) {
3609
- if (found.length >= maxFiles) {
3690
+ if (truncated) {
3610
3691
  return;
3611
3692
  }
3612
3693
  let entries;
@@ -3616,8 +3697,8 @@ async function findFiles(root, extensions, maxFiles) {
3616
3697
  catch {
3617
3698
  return;
3618
3699
  }
3619
- for (const entry of entries) {
3620
- if (found.length >= maxFiles) {
3700
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
3701
+ if (truncated) {
3621
3702
  return;
3622
3703
  }
3623
3704
  if (shouldSkipDirectory(entry.name)) {
@@ -3628,11 +3709,25 @@ async function findFiles(root, extensions, maxFiles) {
3628
3709
  await walk(entryPath);
3629
3710
  }
3630
3711
  else if (extensions.some((extension) => entry.name.endsWith(extension))) {
3712
+ if (found.length >= maxFiles) {
3713
+ truncated = true;
3714
+ return;
3715
+ }
3631
3716
  found.push(entryPath);
3632
3717
  }
3633
3718
  }
3634
3719
  }
3635
3720
  await walk(root);
3721
+ if (scanContext) {
3722
+ scanContext.scans.push({
3723
+ scope,
3724
+ root: path.relative(scanContext.validationRoot, root) || ".",
3725
+ extensions: [...extensions],
3726
+ scanned: found.length,
3727
+ limit: maxFiles,
3728
+ truncated,
3729
+ });
3730
+ }
3636
3731
  return found;
3637
3732
  }
3638
3733
  async function readMany(files) {
@@ -4151,7 +4246,7 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
4151
4246
  if (!matches)
4152
4247
  continue;
4153
4248
  for (const match of matches) {
4154
- if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*:/i.test(match))
4249
+ if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*(?::|[,}])/i.test(match))
4155
4250
  continue;
4156
4251
  if (!/dataType\s*[:=\(]/i.test(match)) {
4157
4252
  findings.push(finding(ruleId, "warning", `File ${rel} creates a custom data post but does not declare a dataType.`, rel, `When creating a post with a custom data payload, the 'dataType' tag must be explicitly provided so the SDK can route it correctly. If missing, it defaults to a text post, which stringifies the payload.`));
@@ -4221,8 +4316,8 @@ function validatePostBodyRendered(root, platform, sourceContent) {
4221
4316
  android: /\b(?:Text\s*\(|TextView|\.text\s*=|setText\s*\()/,
4222
4317
  };
4223
4318
  const postSummaryPat = {
4224
- typescript: /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b|\bpostId\b/,
4225
- "react-native": /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b|\bpostId\b/,
4319
+ typescript: /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4320
+ "react-native": /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4226
4321
  flutter: /\b(?:post|_posts\s*\[[^\]]+\])\s*\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4227
4322
  ios: /\bpost\s*\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4228
4323
  android: /\bpost\s*\.\s*(?:postId|commentsCount|reactionsCount|getPostId|getCommentCount|getReactionCount)\b/,
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { objectInput, optionalNumberField, optionalStringField, stringField, textResult } from "../types.js";
4
4
  import { detectCommandSensors } from "./harness.js";
5
5
  import { inspectProject } from "./project.js";
6
+ const maxCapturedOutputChars = 8000;
6
7
  export const runSensorsTool = {
7
8
  name: "run_sensors",
8
9
  description: "Run detected project command sensors, such as build/typecheck/test, with a timeout and structured results.",
@@ -48,8 +49,29 @@ export const runSensorsTool = {
48
49
  const include = stringArrayField(args, "include");
49
50
  const root = path.resolve(repoPath);
50
51
  const inspection = await inspectProject(root, optionalStringField(args, "surfacePath"));
52
+ if (!inspection.scanCoverage.complete) {
53
+ return textResult({
54
+ status: "incomplete-analysis",
55
+ surfacePath: inspection.selectedSurface?.path,
56
+ targetPlatforms: inspection.platforms,
57
+ scanCoverage: inspection.scanCoverage,
58
+ reason: "Vise could not inspect the complete project inventory, so detected sensors may be incomplete. Narrow the run with --surface/--surface-path.",
59
+ });
60
+ }
51
61
  const detectedSensors = await detectCommandSensors(inspection.effectiveRoot, inspection.platforms);
52
62
  const selectedSensors = include.length > 0 ? detectedSensors.filter((sensor) => include.includes(sensor.name)) : detectedSensors;
63
+ const unmatched = include.filter((requestedName) => !detectedSensors.some((sensor) => sensor.name === requestedName));
64
+ if (unmatched.length > 0) {
65
+ return textResult({
66
+ status: "invalid-selection",
67
+ surfacePath: inspection.selectedSurface?.path,
68
+ targetPlatforms: inspection.platforms,
69
+ requested: include,
70
+ unmatched,
71
+ available: detectedSensors.map((sensor) => sensor.name),
72
+ reason: `No detected sensor matched: ${unmatched.join(", ")}. Explicit --include selections are strict.`,
73
+ });
74
+ }
53
75
  if (dryRun) {
54
76
  return textResult({
55
77
  status: "dry-run",
@@ -91,35 +113,56 @@ async function runSensor(cwd, sensor, timeoutMs) {
91
113
  };
92
114
  }
93
115
  return new Promise((resolve) => {
94
- let stdout = "";
95
- let stderr = "";
116
+ const stdout = { value: "", omitted: 0 };
117
+ const stderr = { value: "", omitted: 0 };
118
+ let environmentProbeTail = "";
119
+ let matchedEnvironmentSkip;
96
120
  let settled = false;
97
121
  const child = spawn(command, args, {
98
122
  cwd,
99
123
  shell: false,
100
124
  env: process.env,
125
+ detached: process.platform !== "win32",
101
126
  });
127
+ const observeEnvironmentOutput = (chunk) => {
128
+ if (!matchedEnvironmentSkip && sensor.environmentSkips?.length) {
129
+ const probe = `${environmentProbeTail}${chunk}`;
130
+ matchedEnvironmentSkip = sensor.environmentSkips.find(({ pattern }) => {
131
+ try {
132
+ return new RegExp(pattern, "i").test(probe);
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ });
138
+ environmentProbeTail = probe.slice(-4096);
139
+ }
140
+ };
102
141
  const timeout = setTimeout(() => {
103
142
  if (settled) {
104
143
  return;
105
144
  }
106
145
  settled = true;
107
- child.kill("SIGTERM");
146
+ terminateProcessTree(child);
108
147
  resolve({
109
148
  name: sensor.name,
110
149
  command: sensor.command,
111
150
  status: "timed-out",
112
151
  durationMs: Date.now() - startedAt,
113
- stdout: truncate(stdout),
114
- stderr: truncate(stderr),
152
+ stdout: capturedOutput(stdout),
153
+ stderr: capturedOutput(stderr),
115
154
  reason: sensor.timeoutReason ?? `Timed out after ${timeoutMs}ms.`,
116
155
  });
117
156
  }, timeoutMs);
118
157
  child.stdout?.on("data", (chunk) => {
119
- stdout += chunk.toString("utf8");
158
+ const text = chunk.toString("utf8");
159
+ captureOutput(stdout, text);
160
+ observeEnvironmentOutput(text);
120
161
  });
121
162
  child.stderr?.on("data", (chunk) => {
122
- stderr += chunk.toString("utf8");
163
+ const text = chunk.toString("utf8");
164
+ captureOutput(stderr, text);
165
+ observeEnvironmentOutput(text);
123
166
  });
124
167
  child.on("error", (error) => {
125
168
  if (settled) {
@@ -127,12 +170,13 @@ async function runSensor(cwd, sensor, timeoutMs) {
127
170
  }
128
171
  settled = true;
129
172
  clearTimeout(timeout);
173
+ captureOutput(stderr, error.message);
130
174
  resolve({
131
175
  name: sensor.name,
132
176
  command: sensor.command,
133
177
  status: "failed",
134
178
  durationMs: Date.now() - startedAt,
135
- stderr: truncate(error.message),
179
+ stderr: capturedOutput(stderr),
136
180
  reason: "Failed to start sensor command.",
137
181
  });
138
182
  });
@@ -142,29 +186,18 @@ async function runSensor(cwd, sensor, timeoutMs) {
142
186
  }
143
187
  settled = true;
144
188
  clearTimeout(timeout);
145
- if (exitCode !== 0 && sensor.environmentSkips?.length) {
146
- const combinedOutput = `${stdout}\n${stderr}`;
147
- const environmentSkip = sensor.environmentSkips.find(({ pattern }) => {
148
- try {
149
- return new RegExp(pattern, "i").test(combinedOutput);
150
- }
151
- catch {
152
- return false;
153
- }
189
+ if (exitCode !== 0 && matchedEnvironmentSkip) {
190
+ resolve({
191
+ name: sensor.name,
192
+ command: sensor.command,
193
+ status: "skipped",
194
+ exitCode,
195
+ durationMs: Date.now() - startedAt,
196
+ stdout: capturedOutput(stdout),
197
+ stderr: capturedOutput(stderr),
198
+ reason: matchedEnvironmentSkip.reason,
154
199
  });
155
- if (environmentSkip) {
156
- resolve({
157
- name: sensor.name,
158
- command: sensor.command,
159
- status: "skipped",
160
- exitCode,
161
- durationMs: Date.now() - startedAt,
162
- stdout: truncate(stdout),
163
- stderr: truncate(stderr),
164
- reason: environmentSkip.reason,
165
- });
166
- return;
167
- }
200
+ return;
168
201
  }
169
202
  resolve({
170
203
  name: sensor.name,
@@ -172,8 +205,8 @@ async function runSensor(cwd, sensor, timeoutMs) {
172
205
  status: exitCode === 0 ? "passed" : "failed",
173
206
  exitCode,
174
207
  durationMs: Date.now() - startedAt,
175
- stdout: truncate(stdout),
176
- stderr: truncate(stderr),
208
+ stdout: capturedOutput(stdout),
209
+ stderr: capturedOutput(stderr),
177
210
  });
178
211
  });
179
212
  });
@@ -209,9 +242,42 @@ function stringArrayField(input, field) {
209
242
  }
210
243
  return value.filter((item) => typeof item === "string" && item.trim() !== "");
211
244
  }
212
- function truncate(value, maxLength = 8000) {
213
- if (value.length <= maxLength) {
214
- return value;
245
+ function captureOutput(output, chunk) {
246
+ const remaining = Math.max(0, maxCapturedOutputChars - output.value.length);
247
+ output.value += chunk.slice(0, remaining);
248
+ output.omitted += Math.max(0, chunk.length - remaining);
249
+ }
250
+ function capturedOutput(output) {
251
+ if (output.omitted === 0) {
252
+ return output.value;
215
253
  }
216
- return `${value.slice(0, maxLength)}\n[truncated ${value.length - maxLength} chars]`;
254
+ return `${output.value}\n[truncated ${output.omitted} chars]`;
255
+ }
256
+ function terminateProcessTree(child) {
257
+ if (!child.pid) {
258
+ child.kill("SIGTERM");
259
+ return;
260
+ }
261
+ if (process.platform === "win32") {
262
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
263
+ stdio: "ignore",
264
+ windowsHide: true,
265
+ });
266
+ killer.unref();
267
+ return;
268
+ }
269
+ try {
270
+ process.kill(-child.pid, "SIGTERM");
271
+ }
272
+ catch {
273
+ child.kill("SIGTERM");
274
+ }
275
+ const forceKill = setTimeout(() => {
276
+ try {
277
+ process.kill(-child.pid, "SIGKILL");
278
+ }
279
+ catch {
280
+ }
281
+ }, 1000);
282
+ forceKill.unref();
217
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amityco/social-plus-vise",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Skill-guided deterministic CLI for social.plus SDK integration assistance.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  "ai-coding"
23
23
  ],
24
24
  "engines": {
25
- "node": ">=18"
25
+ "node": ">=20"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"