@amityco/social-plus-vise 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ 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.0 — 2026-07-10
8
+
9
+ ### Added
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+
14
+ ### Changed
15
+ - **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`.
16
+ - **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.
17
+ - **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.
18
+ - **Node.js 20 is the minimum supported runtime.** Package metadata, `vise doctor`, release documentation, and hosted compatibility proof now agree.
19
+ - **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.
20
+
21
+ ### Fixed
22
+ - **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.
23
+ - **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.
24
+ - **Flutter hosted block dependencies.** Hosted constraints now render as `social_plus_blocks: ^0.1.0`; only local sources render a nested `path:` dependency.
25
+ - **Fixture-symmetry parsing.** The supplementary symmetry gate parses only `failingFixturesByFinding`, so later no-fire maps cannot overwrite the failing fixture associated with a rule.
26
+
27
+ ### Compatibility
28
+ - 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.
29
+
7
30
  ## 1.7.0 — 2026-07-09
8
31
 
9
32
  ### Added
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]
@@ -2588,8 +2593,11 @@ function ciCheckResult(result) {
2588
2593
  "needs-attestation",
2589
2594
  "completeness-gap",
2590
2595
  "selected-capability-failures",
2596
+ "engagement-drift",
2591
2597
  ],
2592
- message: result.exitCode === 0 ? "Compliance green for CI." : `Compliance failed for CI with status: ${result.status}.`,
2598
+ message: result.exitCode === 0
2599
+ ? "Active and recorded engagement compliance is green for CI."
2600
+ : `Compliance failed for CI with status: ${result.status}.`,
2593
2601
  },
2594
2602
  };
2595
2603
  }
@@ -2791,7 +2799,7 @@ function doctorResult() {
2791
2799
  packageRoot,
2792
2800
  status: nodeMajor >= 18 ? "ok" : "unsupported-node",
2793
2801
  node: process.versions.node,
2794
- requiredNodeMajor: ">=18",
2802
+ requiredNodeMajor: ">=20",
2795
2803
  docsSource: process.env.SOCIAL_PLUS_DOCS_ROOT ? "local" : "hosted",
2796
2804
  docsRoot: process.env.SOCIAL_PLUS_DOCS_ROOT,
2797
2805
  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) {
@@ -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*\(/]);
@@ -3252,7 +3324,7 @@ function iosAmitySdkIsFloating(s) {
3252
3324
  return true;
3253
3325
  return false;
3254
3326
  }
3255
- async function validateIos(root) {
3327
+ async function validateIos(root, scanContext) {
3256
3328
  const findings = [];
3257
3329
  const joinExisting = async (names) => [...(await readMany(await existingFiles(root, names))).values()].join("\n");
3258
3330
  const podText = await joinExisting(["Podfile"]);
@@ -3277,7 +3349,7 @@ async function validateIos(root) {
3277
3349
  ];
3278
3350
  const manifestCombined = [podText, pkgSwiftText, projectYmlText, pbxprojText].join("\n");
3279
3351
  const hasManifest = manifestCombined.trim().length > 0;
3280
- const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400);
3352
+ const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400, scanContext, "ios.source");
3281
3353
  const swiftContent = await readMany(swiftFiles);
3282
3354
  const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
3283
3355
  const coreLoginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
@@ -3603,10 +3675,11 @@ function inferDesignKind(relativePath) {
3603
3675
  }
3604
3676
  return "theme";
3605
3677
  }
3606
- async function findFiles(root, extensions, maxFiles) {
3678
+ async function findFiles(root, extensions, maxFiles, scanContext, scope = "source") {
3607
3679
  const found = [];
3680
+ let truncated = false;
3608
3681
  async function walk(directory) {
3609
- if (found.length >= maxFiles) {
3682
+ if (truncated) {
3610
3683
  return;
3611
3684
  }
3612
3685
  let entries;
@@ -3616,8 +3689,8 @@ async function findFiles(root, extensions, maxFiles) {
3616
3689
  catch {
3617
3690
  return;
3618
3691
  }
3619
- for (const entry of entries) {
3620
- if (found.length >= maxFiles) {
3692
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
3693
+ if (truncated) {
3621
3694
  return;
3622
3695
  }
3623
3696
  if (shouldSkipDirectory(entry.name)) {
@@ -3628,11 +3701,25 @@ async function findFiles(root, extensions, maxFiles) {
3628
3701
  await walk(entryPath);
3629
3702
  }
3630
3703
  else if (extensions.some((extension) => entry.name.endsWith(extension))) {
3704
+ if (found.length >= maxFiles) {
3705
+ truncated = true;
3706
+ return;
3707
+ }
3631
3708
  found.push(entryPath);
3632
3709
  }
3633
3710
  }
3634
3711
  }
3635
3712
  await walk(root);
3713
+ if (scanContext) {
3714
+ scanContext.scans.push({
3715
+ scope,
3716
+ root: path.relative(scanContext.validationRoot, root) || ".",
3717
+ extensions: [...extensions],
3718
+ scanned: found.length,
3719
+ limit: maxFiles,
3720
+ truncated,
3721
+ });
3722
+ }
3636
3723
  return found;
3637
3724
  }
3638
3725
  async function readMany(files) {
@@ -4151,7 +4238,7 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
4151
4238
  if (!matches)
4152
4239
  continue;
4153
4240
  for (const match of matches) {
4154
- if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*:/i.test(match))
4241
+ if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*(?::|[,}])/i.test(match))
4155
4242
  continue;
4156
4243
  if (!/dataType\s*[:=\(]/i.test(match)) {
4157
4244
  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 +4308,8 @@ function validatePostBodyRendered(root, platform, sourceContent) {
4221
4308
  android: /\b(?:Text\s*\(|TextView|\.text\s*=|setText\s*\()/,
4222
4309
  };
4223
4310
  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/,
4311
+ typescript: /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4312
+ "react-native": /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4226
4313
  flutter: /\b(?:post|_posts\s*\[[^\]]+\])\s*\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4227
4314
  ios: /\bpost\s*\.\s*(?:postId|commentsCount|reactionsCount)\b/,
4228
4315
  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.0",
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"