@amityco/social-plus-vise 0.14.27 → 0.14.28

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,47 @@ 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
+ ## 0.14.28 — 2026-06-10
8
+
9
+ **Theme:** block-aware completeness — install a block, end green. Plus platform-leg hardening: Swift gets the tree-sitter treatment and iOS gets a guarded build sensor.
10
+
11
+ ### Added
12
+ - **Field-level SDK model facts (sdk-facts Phase 4):** `vise sdk-facts` / `get_sdk_facts` no longer prove symbol existence only. The bundled snapshot now includes `sdk-surface/models.<platform>.json` (wire format `2026-06-10.sdk-model-facts.v1`, owned by `packages/schemas/model-facts.schema.json` with a type-only TS twin) carrying capability-anchored model schemas — per field: `name`, `type` (the platform's own string form), `optional`, `memberKind`, and `declaredIn` source anchor — plus full extraction provenance (extractor id/version, timestamp, upstream docs-ops extractor, upstream git commit). `--include-models` (MCP `includeModels`) serves the schemas; capability model entries gain a verbatim `schema`; every result carries a `modelFacts` status so absence is explicit.
13
+ - **Extraction-grounded by construction:** `scripts/extract-sdk-models.mjs` (run by `sdk-surface:import`) grounds each platform mechanically — TypeScript via the compiler API over the local SDK sources (`names-and-types`, including `?` optionality and file:line), Android via dokka signature data (`names-and-types`, Kotlin `?` nullability, doc-prose stripped), iOS/Flutter as `names-only` because their docc-abi/dart exports carry no type data (the schema then forces field `type`/`optional` to `null` — fabricated detail cannot validate). A platform whose field source is unreachable ships no model file and the manifest records why. Scope is limited to the first factory capabilities (comments, reactions, posts, users): npm tarball grew 479.2 kB → 484.2 kB.
14
+ - **Reaction model anchors:** the `reactions` capability now also anchors `Amity.Reactor` and `Amity.Reactable` (the reaction-summary shape blocks actually consume), alongside `Amity.Reaction`.
15
+
16
+ ### Verified (model facts)
17
+ - `test:sdk-surface` locks the manifest `models` map (sha256, per-platform grounding) and one real extracted model per platform — including that `Amity.Comment` carries `userId` but **not** the imagined `creatorId`/`isHidden` fields that previously lived in hand-written factory fixtures.
18
+ - `test:sdk-facts` covers `modelFacts` status, capability schema attachment, `includeModels` capability filtering, names-only null enforcement, and the CLI `--include-models` path.
19
+ - The schemas package self-test validates every committed snapshot against `model-facts.schema.json` and proves the schema bites (11 doctored snapshots fail, including fabricated types under names-only grounding).
20
+ - **Swift AST validators (tree-sitter-swift 0.7.x):** the highest-false-positive-risk iOS rules move from regex to the same tree-sitter helper layer Kotlin uses (`tree-sitter-swift` is the maintained alex-pinkus grammar; its node shapes descend from the Kotlin grammar, so `findCallExpressions` shares the walk and adds Swift labeled-argument extraction via the new `pickLabeledArgument`). Migrated rules — ids, severities, and pass/fail semantics unchanged, only precision improved:
21
+ - `ios.auth.no-literal-user-id`, `ios.secret.inline-api-key`, `ios.feed.target.literal` now resolve **indirect literals** (`let FALLBACK_USER = "demo-user-42"` … `login(userId: FALLBACK_USER)`) that the call-site regex could never see — proven by the new `ios-userid-via-constant` / `ios-secret-via-constant` / `ios-feed-target-via-constant` fixtures.
22
+ - `ios.session-handler.retained` asks the real scope question (is the binding inside a function body?) instead of bridging 200 chars from any `func … {`. A bare class-scope `let sessionHandler = …` directly after a short function used to false-fire (the rescue pattern demanded an access modifier); the new `ios-session-handler-class-scope-bare` fixture locks the fix. Type-annotated locals (`let h: AmitySessionHandler = …`) the regex bridge missed are now caught.
23
+ - `ios.user.ban-state-respected` detects its interaction surface on the comment-stripped view — `// TODO: wire createPost here` no longer counts as an interaction (new `ios-ban-state-comment-only` fixture); the `// vise: ban state checked at` escape hatch deliberately stays on raw text.
24
+ - All Swift gate rules now use the precise tree-sitter comment stripper, chained onto the conservative scanner so bindings-unavailable/oversized files degrade to the previous behavior, never to raw text. `ios-happy-path` stays at zero findings (FP canary), and a missing `tree-sitter-swift` prebuild degrades only the Swift helpers — ts/tsx/kotlin AST stays up.
25
+ - **Dart AST honestly skipped:** every Dart grammar on npm fails against the pinned `tree-sitter@^0.21` (stale nan binding, ≥0.22-only API, forked core, WASM-only, or an unmaintained vendor-internal fork). Dart rules remain regex + conservative scanner; the verdict and re-evaluation trigger are recorded in ARCHITECTURE.md.
26
+ - **iOS build sensor — guarded best-effort (revises the old "xcodebuild is too fragile to wire" decision):** an `.xcodeproj`/`.xcworkspace` at the surface root now enables two sensors, **only when `xcodebuild` is on PATH** — absent, `vise run-sensors` reports the sensor `skipped` with the install-Xcode precondition (the pf-003 visible-precondition pattern) instead of returning no-sensors. When runnable: a cheap `xcodebuild -list` integrity probe, then a bounded-timeout build with signing disabled (`CODE_SIGNING_ALLOWED=NO`/`CODE_SIGNING_REQUIRED=NO`/`CODE_SIGN_IDENTITY=`). Non-zero exits are classified before being reported: environment-caused failures (Command-Line-Tools-only `xcode-select`, unaccepted Xcode license, signing/provisioning demands, missing simulator/SDK destination, undeterminable workspace scheme) become **skipped-with-reason and exit 0** — never project failure — while real compile/link errors stay `failed` with a non-zero CLI exit (pf-004-era semantics). The classification channel is a new serializable `environmentSkips` field on command sensors, available to any future toolchain with the same exit-code conflation. Locked by a stub-`xcodebuild` controlled-PATH matrix in `test:sensors` (detected+green, detected+real-failure, environmental skips, absent toolchain).
27
+ - **Installed blocks satisfy completeness baselines:** `vise check` previously scanned customer source only, so a capability delivered inside an installed Block Factory package (e.g. the Comments block's composer) still reported `completeness-gap` (exit 5). Now a checklist capability counts as present with evidence `source: "block:<blockId>"` (and a note naming the block) when an installed sidecar entry declares it and the install is still locally valid.
28
+ - **Factory declares, Vise validates:** Block Factory contracts declare `providesCapabilities` using Vise's completeness-checklist id vocabulary (Vise owns the id space, the factory owns the per-block claim; an empty list is legitimate — the Reactions block provides no baseline ids). `vise blocks plan`/`add` carry the registry entry's ids into the install plan and `--apply` records them — plus the manifest `dependencyName` — in `sp-vise/blocks.json` (sidecar `schemaVersion` bumped to `2026-06-10.vise-blocks-sidecar.v1`).
29
+ - **Seam guard:** `vise blocks plan`/`add`/`validate` emit a `blocks.providesCapabilities.known` **warning** finding (never a failure) for declared ids outside Vise's completeness vocabulary; unknown ids never satisfy a gap.
30
+
31
+ ### Changed
32
+ - **Skill: bounded-turn stop guidance.** Agents running in one-shot/print mode are told to reach a `vise check` verdict before the turn ends rather than ending mid-environment-setup (finding from the first semantic harness-E2E measurement).
33
+ - **Gap returns on drift:** the block evidence is registry-free and re-validated on every `vise check` — the block's dependency must still be declared in the project manifest and every recorded `filesTouched` path must exist. Remove the touched source file or the package and the capability reverts to the normal `completeness-gap`. Pre-`0.14.28` sidecar entries carry no package evidence and fail closed.
34
+
35
+ ### Verified
36
+ - `test:ast` extended with the Swift section: grammar availability, labeled-argument call extraction, identifier→literal resolution (with the env-fallback `?? ""` and interpolated-string non-resolution cases), comment stripping that preserves `//` inside strings, function-local vs type-scope handler declarations, the three indirect-literal fixtures, and the two false-positive-kill fixtures (each verified to fire under the pre-change regexes). `ios-happy-path` asserted at zero findings; existing `ios-session-handler-local-var`/`-retained` pass/fail semantics asserted unchanged.
37
+ - `test:sensors` gains the stub-`xcodebuild` controlled-PATH matrix: detection with/without the toolchain, detected+green (exit 0), detected+real-build-failure (exit 1, not environment-skipped), no-simulator and CLT-only environmental skips (exit 0), and the absent-toolchain skip end to end.
38
+ - New `test:blocks-completeness` gate: real `blocks add --apply` against the monorepo Block Factory registry, init with answers, `check --ci` green via `block:comments` evidence, gap restored after deleting a touched file and after removing the manifest dependency, empty `providesCapabilities` tolerated end to end, and the unknown-id warning on a doctored registry.
39
+ - The Block Factory react target-project journey (`validate:target-projects:react`) now asserts the full install journey ends **green** with `comment-composer` satisfied by `block:comments`.
40
+
41
+ ### Internal
42
+ - **Registry seam contract extracted to `packages/schemas`:** the block-registry wire format and the `sp-vise/blocks.json` sidecar shape now live in the root workspace package `@amityco/social-plus-schemas` (private, unpublished) — a JSON Schema (`registry.schema.json`) enforced on the factory's `validate:registry`, plus the TypeScript types formerly hand-written in `src/tools/blocks.ts`. Vise imports them **type-only** via a `workspace:*` devDependency, so nothing from the package survives into the published `dist/` (proven by the packed-tarball E2E). Type-only refactor; no behavior change.
43
+
44
+ ### Fixed
45
+ - **Attestation evidence fingerprints no longer truncate prose-embedded paths:** the source-path extractor's extension alternation was shortest-first, so `.tsx`→`.ts`, `.json`→`.js`, and `.kts`→`.kt` inside evidence prose silently recorded no fingerprint (weakening drift detection for that attestation). Found by the harness E2E attestation stage, which now cites paths in prose as the standing regression test.
46
+
47
+
7
48
  ## 0.14.27 — 2026-06-10
8
49
 
9
50
  **Theme:** social-plus-forge monorepo move. No runtime behavior changes.
package/README.md CHANGED
@@ -77,9 +77,9 @@ Vise validates on three layers, and the layer is set by the *kind of claim* —
77
77
  |---|---|---|---|
78
78
  | **SDK compliance** | "this is **wrong**" | 300+ deterministic rules (session renewal, live-collection vs one-shot, no secret in logs, parent-child rendering, ban-state gating…) | **Hard gate** — `vise check` blocks until green or attested. A small advisory subset surfaces as informational only and never blocks. |
79
79
  | **Design conformance** | "this **looks off**" | extract the customer's design system into a contract, render a preview for confirmation, then check token usage | **Advisory** — `vise design check`/`preview`; never fails a build |
80
- | **Feature completeness** | "this is **missing**" | Vise proposes a narrow baseline per outcome; for add-feed, pagination is mandatory, for add-comments, the composer/write affordance is mandatory, for add-chat, send plus read/unread state are mandatory, and for add-follow/profile, SDK-backed follower/following data is mandatory, while richer feed capabilities are opt-in choices from `vise plan` | **Decision gate** — `vise check` exits `completeness-gap` until each baseline capability is built or validly opted out; selected optional capabilities run separate sensors |
80
+ | **Feature completeness** | "this is **missing**" | Vise proposes a narrow baseline per outcome; for add-feed, pagination is mandatory, for add-comments, the composer/write affordance is mandatory, for add-chat, send plus read/unread state are mandatory, and for add-follow/profile, SDK-backed follower/following data is mandatory, while richer feed capabilities are opt-in choices from `vise plan` | **Decision gate** — `vise check` exits `completeness-gap` until each baseline capability is built, satisfied by an installed Block Factory block (evidence `source: "block:<id>"`, re-validated locally on every check), or validly opted out; selected optional capabilities run separate sensors |
81
81
 
82
- Correctness is gated by deterministic rules or attestations. Baseline completeness is gated by explicit scope decisions: if a baseline capability is legitimately out of scope, record `// vise: scope-omit <id> — <reason>` and it no longer blocks. Optional feed capabilities such as image upload, poll creation, and edit post are offered during planning and become checked only after the user opts in. Conformance remains advisory because "matches the brand" is legitimately subjective. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
82
+ Correctness is gated by deterministic rules or attestations. Baseline completeness is gated by explicit scope decisions: if a baseline capability is legitimately out of scope, record `// vise: scope-omit <id> — <reason>` and it no longer blocks. Installed Block Factory blocks count too: the factory declares the completeness ids a block provides (`providesCapabilities`), `vise blocks add --apply` records them in `sp-vise/blocks.json`, and `vise check` honours them only while the block's manifest dependency and touched files are intact — on drift the gap returns. Optional feed capabilities such as image upload, poll creation, and edit post are offered during planning and become checked only after the user opts in. Conformance remains advisory because "matches the brand" is legitimately subjective. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
83
83
 
84
84
  ### Engagement Intelligence roadmap
85
85
 
@@ -232,7 +232,7 @@ The benchmark suite is intentionally reported with boundaries:
232
232
  | **React Native** | ✅ Full | `tsc`, `npm lint`, SDK import smoke |
233
233
  | **Flutter / Dart** | ✅ Full | `flutter analyze`, `flutter test` |
234
234
  | **Android (Kotlin)** | ✅ Full | Gradle assemble, unit tests |
235
- | **iOS (Swift)** | ✅ Full | Static rule checks fully operational. Build sensor not wired (`xcodebuild` environment requirements make it fragile) — `vise run-sensors` returns no-sensors for iOS; compliance rules run regardless. |
235
+ | **iOS (Swift)** | ✅ Full | Static rule checks fully operational (highest-risk rules use the Swift tree-sitter AST). Build sensor is **guarded best-effort**: an `.xcodeproj`/`.xcworkspace` enables an `xcodebuild -list` integrity probe plus a signing-disabled build only when `xcodebuild` is on PATH (absent → skipped with the visible precondition). Environment-caused failures (no simulator runtime, Command-Line-Tools-only `xcode-select`, signing demands, unaccepted license) report **skipped-with-reason**, never project failure; a real build failure still exits non-zero. |
236
236
 
237
237
  Each platform has dozens of rules across 10 compliance domains (feed, comments, moderation, chat, secrets, session & auth, notifications, live objects, logging hygiene, design tokens).
238
238
 
@@ -1187,6 +1187,50 @@ function baselineCapabilities(outcome) {
1187
1187
  export function capabilityChecklist(outcome) {
1188
1188
  return baselineCapabilities(outcome).map((c) => ({ id: c.id, label: c.label, hint: c.hint }));
1189
1189
  }
1190
+ /**
1191
+ * The Vise-owned completeness id vocabulary — every id a Block Factory contract
1192
+ * may reference in `providesCapabilities`. Vise owns this id space; the factory
1193
+ * owns the per-block declaration (see block-factory/docs/vise-boundary.md).
1194
+ */
1195
+ export function completenessCapabilityIds() {
1196
+ return new Set(CAPABILITIES.map((capability) => capability.id));
1197
+ }
1198
+ /**
1199
+ * Overlay installed-block evidence onto a source-scan completeness assessment.
1200
+ * A still-missing checklist capability becomes present with evidence
1201
+ * `source: "block:<blockId>"` when a locally validated installed block declares
1202
+ * it in `providesCapabilities`. Callers are responsible for the local validation
1203
+ * (package declared + filesTouched intact); on drift they must simply not pass
1204
+ * the capability here, so the normal gap returns.
1205
+ */
1206
+ export function applyBlockProvidedCompleteness(assessment, provided) {
1207
+ if (provided.length === 0 || assessment.missing.length === 0) {
1208
+ return assessment;
1209
+ }
1210
+ const blockByCapability = new Map();
1211
+ for (const item of provided) {
1212
+ if (!blockByCapability.has(item.capabilityId)) {
1213
+ blockByCapability.set(item.capabilityId, item.blockId);
1214
+ }
1215
+ }
1216
+ const present = [...assessment.present];
1217
+ const missing = [];
1218
+ for (const item of assessment.missing) {
1219
+ const blockId = blockByCapability.get(item.id);
1220
+ if (blockId) {
1221
+ present.push({
1222
+ id: item.id,
1223
+ label: item.label,
1224
+ source: `block:${blockId}`,
1225
+ note: `Satisfied by installed block "${blockId}" (sp-vise/blocks.json): the block package delivers this capability and its local install validation passed.`,
1226
+ });
1227
+ }
1228
+ else {
1229
+ missing.push(item);
1230
+ }
1231
+ }
1232
+ return { ...assessment, present, missing };
1233
+ }
1190
1234
  /** Optional feed-forward choices. These are not baseline completeness requirements. */
1191
1235
  export function optionalCapabilityChecklist(outcome, availableIds) {
1192
1236
  const available = availableIds ? new Set(availableIds) : undefined;
package/dist/server.js CHANGED
@@ -342,7 +342,7 @@ async function handleCli(args) {
342
342
  return "exit";
343
343
  }
344
344
  if (command === "sdk-facts" || command === "sdk_facts") {
345
- assertOnlyKnownFlags(args, ["platform", "capability", "surface-dir", "format", "include-symbols"], "sdk-facts");
345
+ assertOnlyKnownFlags(args, ["platform", "capability", "surface-dir", "format", "include-symbols", "include-models"], "sdk-facts");
346
346
  const format = flagValue(args, "format") ?? "json";
347
347
  if (format !== "json") {
348
348
  throw new Error("sdk-facts currently supports --format json only.");
@@ -352,6 +352,7 @@ async function handleCli(args) {
352
352
  capability: flagValue(args, "capability"),
353
353
  surfaceDir: flagValue(args, "surface-dir"),
354
354
  includeSymbols: hasFlag(args, "include-symbols"),
355
+ includeModels: hasFlag(args, "include-models"),
355
356
  });
356
357
  return "exit";
357
358
  }
@@ -776,10 +777,15 @@ Usage:
776
777
  return `${packageName} sdk-facts
777
778
 
778
779
  Read bundled SDK surface facts for social.plus Block Factory planning. Internal, projectless, and read-only.
780
+ Symbol facts prove existence; --include-models adds extraction-grounded field-level model schemas
781
+ (modelSchemas, plus capability model entries gain a schema). Platforms without grounded field data
782
+ report modelFacts.status=absent instead of fabricated fields.
779
783
 
780
784
  Usage:
781
785
  vise sdk-facts --platform typescript --capability comments --format json
786
+ vise sdk-facts --platform typescript --capability comments --include-models
782
787
  vise sdk-facts --platform react-native --capability reactions --include-symbols
788
+ vise sdk-facts --platform android --include-models --format json
783
789
  vise sdk-facts --platform android --surface-dir ./sdk-surface --format json`;
784
790
  }
785
791
  if (command === "blocks") {
package/dist/tools/ast.js CHANGED
@@ -29,11 +29,21 @@ function loadNativeBindings() {
29
29
  const ParserCtor = nodeRequire("tree-sitter");
30
30
  const tsGrammars = nodeRequire("tree-sitter-typescript");
31
31
  const kotlinGrammar = nodeRequire("tree-sitter-kotlin");
32
+ // Swift loads in its own try/catch: a missing tree-sitter-swift prebuild on an
33
+ // exotic platform must degrade ONLY the Swift helpers, not all AST analysis.
34
+ let swiftGrammar = null;
35
+ try {
36
+ swiftGrammar = nodeRequire("tree-sitter-swift");
37
+ }
38
+ catch {
39
+ swiftGrammar = null;
40
+ }
32
41
  nativeBindings = {
33
42
  Parser: ParserCtor,
34
43
  tsGrammar: tsGrammars.typescript,
35
44
  tsxGrammar: tsGrammars.tsx,
36
45
  kotlinGrammar,
46
+ swiftGrammar,
37
47
  };
38
48
  }
39
49
  catch {
@@ -50,6 +60,14 @@ function loadNativeBindings() {
50
60
  export function astAvailable() {
51
61
  return loadNativeBindings() !== null;
52
62
  }
63
+ /**
64
+ * Whether the Swift grammar specifically is available. tree-sitter-swift is loaded
65
+ * independently of the core bindings (see loadNativeBindings), so Swift-only
66
+ * validators can check this and fall back to regex without disabling ts/tsx/kotlin.
67
+ */
68
+ export function swiftAstAvailable() {
69
+ return loadNativeBindings()?.swiftGrammar != null;
70
+ }
53
71
  /**
54
72
  * Strip comments from source code using tree-sitter AST.
55
73
  * Replaces comment spans with whitespace (preserving line structure).
@@ -101,6 +119,12 @@ function getParser(language) {
101
119
  parser.setLanguage(native.tsxGrammar);
102
120
  else if (language === "kotlin")
103
121
  parser.setLanguage(native.kotlinGrammar);
122
+ else if (language === "swift") {
123
+ if (native.swiftGrammar == null) {
124
+ throw new Error("tree-sitter-swift unavailable; Swift AST analysis disabled (regex fallback in effect)");
125
+ }
126
+ parser.setLanguage(native.swiftGrammar);
127
+ }
104
128
  else
105
129
  parser.setLanguage(native.tsGrammar);
106
130
  parsers.set(language, parser);
@@ -166,7 +190,10 @@ export function findCallExpressions(tree, calleePattern) {
166
190
  results.push({ callee, node, args });
167
191
  return;
168
192
  }
169
- // Kotlin: call_expression = navigation_expression + call_suffix
193
+ // Kotlin AND Swift: call_expression = navigation_expression + call_suffix.
194
+ // (tree-sitter-swift descends from the Kotlin grammar, so the node names —
195
+ // navigation_expression / navigation_suffix / call_suffix / value_arguments —
196
+ // are identical; only Swift's labeled arguments add a value_argument_label.)
170
197
  const navNode = node.namedChild(0);
171
198
  const suffixNode = node.namedChild(1);
172
199
  if (!navNode || !suffixNode || suffixNode.type !== "call_suffix")
@@ -181,8 +208,11 @@ export function findCallExpressions(tree, calleePattern) {
181
208
  for (let i = 0; i < valArgsNode.namedChildCount; i++) {
182
209
  const valArg = valArgsNode.namedChild(i);
183
210
  if (valArg && valArg.type === "value_argument") {
184
- // The actual expression is inside value_argument
185
- const expr = valArg.namedChild(0);
211
+ // The actual expression is inside value_argument. Swift labeled arguments
212
+ // put a value_argument_label first — the value is the last named child.
213
+ // Kotlin behaviour (namedChild(0)) is intentionally unchanged.
214
+ const first = valArg.namedChild(0);
215
+ const expr = first?.type === "value_argument_label" ? valArg.namedChild(valArg.namedChildCount - 1) : first;
186
216
  if (expr)
187
217
  args.push(expr);
188
218
  }
@@ -233,6 +263,93 @@ export function pickObjectProperty(objectNode, propertyName) {
233
263
  }
234
264
  return undefined;
235
265
  }
266
+ /**
267
+ * Pick the value of a labeled argument from a Swift call expression node.
268
+ * E.g., from `client.login(userId: HARDCODED, sessionHandler: handler)`,
269
+ * pick the value node for label "userId".
270
+ *
271
+ * Swift-shaped trees only (call_suffix → value_arguments → value_argument with a
272
+ * value_argument_label). Returns undefined when the label is absent.
273
+ */
274
+ export function pickLabeledArgument(callNode, label) {
275
+ for (let i = 0; i < callNode.namedChildCount; i++) {
276
+ const suffix = callNode.namedChild(i);
277
+ if (!suffix || suffix.type !== "call_suffix")
278
+ continue;
279
+ const valArgs = suffix.namedChild(0);
280
+ if (!valArgs || valArgs.type !== "value_arguments")
281
+ continue;
282
+ for (let j = 0; j < valArgs.namedChildCount; j++) {
283
+ const valArg = valArgs.namedChild(j);
284
+ if (!valArg || valArg.type !== "value_argument")
285
+ continue;
286
+ const first = valArg.namedChild(0);
287
+ if (first?.type !== "value_argument_label" || first.text !== label)
288
+ continue;
289
+ const value = valArg.namedChild(valArg.namedChildCount - 1);
290
+ // namedChild(last) === the label itself when the argument has no value
291
+ // (selector-reference form) — treat as absent.
292
+ return value && value !== first ? value : undefined;
293
+ }
294
+ }
295
+ return undefined;
296
+ }
297
+ /**
298
+ * Find Swift `let`/`var` declarations whose initializer text matches a pattern AND
299
+ * which are function-local (declared inside a function/initializer/closure/computed-
300
+ * property body rather than at type or file scope). Used for retention rules: a
301
+ * function-local binding dies with the stack frame, while a type-scope property
302
+ * lives with the object — a distinction regex bridges cannot make reliably.
303
+ *
304
+ * Conservative direction: a declaration only counts as local when a KNOWN
305
+ * function-ish ancestor encloses it, so unknown containers degrade toward
306
+ * "retained" (quiet), never toward a false positive.
307
+ */
308
+ export function findSwiftFunctionLocalDeclarations(tree, initializerPattern) {
309
+ const results = [];
310
+ walkTree(tree.rootNode, (node) => {
311
+ if (node.type !== "property_declaration")
312
+ return;
313
+ const value = swiftPropertyInitializer(node);
314
+ if (!value || !initializerPattern.test(value.text))
315
+ return;
316
+ if (hasFunctionAncestor(node))
317
+ results.push(node);
318
+ });
319
+ return results;
320
+ }
321
+ const SWIFT_FUNCTION_SCOPES = new Set([
322
+ "function_declaration",
323
+ "init_declaration",
324
+ "deinit_declaration",
325
+ "lambda_literal",
326
+ "computed_property",
327
+ "computed_getter",
328
+ "computed_setter",
329
+ ]);
330
+ function hasFunctionAncestor(node) {
331
+ let current = node.parent;
332
+ while (current) {
333
+ if (SWIFT_FUNCTION_SCOPES.has(current.type))
334
+ return true;
335
+ current = current.parent;
336
+ }
337
+ return false;
338
+ }
339
+ /** The initializer expression of a Swift property_declaration (the value after `=`), if any. */
340
+ function swiftPropertyInitializer(node) {
341
+ // Shape: property_declaration = value_binding_pattern, pattern, [type_annotation], [value expr]
342
+ // The initializer (when present) is the last named child and is none of the structural parts.
343
+ const last = node.namedChild(node.namedChildCount - 1);
344
+ if (!last)
345
+ return undefined;
346
+ if (["value_binding_pattern", "pattern", "type_annotation", "modifiers", "attribute"].includes(last.type))
347
+ return undefined;
348
+ // computed_property is a body, not an initializer.
349
+ if (last.type === "computed_property")
350
+ return undefined;
351
+ return last;
352
+ }
236
353
  // ── Internal helpers ──────────────────────────────────────────────────────────
237
354
  function walkTree(node, visit) {
238
355
  visit(node);
@@ -322,6 +439,25 @@ function extractStringLiteral(node) {
322
439
  return text.slice(1, -1);
323
440
  }
324
441
  }
442
+ // Swift: line_string_literal contains line_str_text chunks; an interpolated
443
+ // string has >1 named child (line_str_text + interpolated_expression) and is
444
+ // NOT statically resolvable — mirror the TS template-literal treatment.
445
+ if (node.type === "line_string_literal") {
446
+ if (node.namedChildCount === 0)
447
+ return ""; // empty string ""
448
+ if (node.namedChildCount === 1 && node.namedChild(0)?.type === "line_str_text") {
449
+ return node.namedChild(0).text;
450
+ }
451
+ return undefined;
452
+ }
453
+ // Swift: multi-line """…""" literal. Swift semantics strip the newline after the
454
+ // opening and before the closing delimiter.
455
+ if (node.type === "multi_line_string_literal") {
456
+ if (node.namedChildCount === 1 && node.namedChild(0)?.type === "multi_line_str_text") {
457
+ return node.namedChild(0).text.replace(/^\n/, "").replace(/\n[ \t]*$/, "");
458
+ }
459
+ return undefined;
460
+ }
325
461
  return undefined;
326
462
  }
327
463
  function resolveIdentifierToLiteral(name, root) {
@@ -345,15 +481,31 @@ function resolveIdentifierToLiteral(name, root) {
345
481
  // Kotlin: property_declaration with variable_declaration + string_literal
346
482
  if (node.type === "property_declaration") {
347
483
  const varDecl = node.namedChildren.find((c) => c.type === "variable_declaration");
348
- if (!varDecl)
484
+ if (varDecl) {
485
+ const idNode = varDecl.namedChildren.find((c) => c.type === "simple_identifier");
486
+ if (!idNode || idNode.text !== name)
487
+ return;
488
+ const strLit = node.namedChildren.find((c) => c.type === "string_literal");
489
+ if (!strLit)
490
+ return;
491
+ const literal = extractStringLiteral(strLit);
492
+ if (literal !== undefined)
493
+ result = literal;
494
+ return;
495
+ }
496
+ // Swift: property_declaration with pattern → simple_identifier; the string
497
+ // literal must be a DIRECT child (the initializer) — a literal nested inside
498
+ // e.g. `env["KEY"] ?? ""` is not a static binding and must not resolve.
499
+ const pattern = node.namedChildren.find((c) => c.type === "pattern");
500
+ if (!pattern)
349
501
  return;
350
- const idNode = varDecl.namedChildren.find((c) => c.type === "simple_identifier");
351
- if (!idNode || idNode.text !== name)
502
+ const swiftId = pattern.namedChildren.find((c) => c.type === "simple_identifier");
503
+ if (!swiftId || swiftId.text !== name)
352
504
  return;
353
- const strLit = node.namedChildren.find((c) => c.type === "string_literal");
354
- if (!strLit)
505
+ const swiftLit = node.namedChildren.find((c) => c.type === "line_string_literal" || c.type === "multi_line_string_literal");
506
+ if (!swiftLit)
355
507
  return;
356
- const literal = extractStringLiteral(strLit);
508
+ const literal = extractStringLiteral(swiftLit);
357
509
  if (literal !== undefined)
358
510
  result = literal;
359
511
  }
@@ -1,9 +1,11 @@
1
1
  import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { completenessCapabilityIds } from "../capabilities.js";
3
4
  import { packageVersion } from "../version.js";
4
5
  import { inspectProject } from "./project.js";
5
6
  import { detectCommandSensors } from "./harness.js";
6
7
  import { readDesignContract } from "./design.js";
8
+ const blocksSidecarSchemaVersion = "2026-06-10.vise-blocks-sidecar.v1";
7
9
  const registryPlatformByVisePlatform = {
8
10
  typescript: "react",
9
11
  "react-native": "react-native",
@@ -21,6 +23,7 @@ export async function listRegistryBlocks(registryPath) {
21
23
  status: block.status,
22
24
  surfaces: block.surfaces,
23
25
  requiredSdkCapabilities: block.requiredSdkCapabilities,
26
+ providesCapabilities: providesCapabilitiesFor(block),
24
27
  events: block.events,
25
28
  })),
26
29
  };
@@ -88,7 +91,8 @@ export async function validateBlockInstall(options) {
88
91
  const repoPath = requiredRepoPath(options);
89
92
  const sidecar = await readSidecar(repoPath);
90
93
  const installed = (sidecar?.installed ?? []).filter((entry) => !options.blockId || entry.blockId === options.blockId);
91
- const findings = [];
94
+ // Seed with plan findings so the providesCapabilities seam guard also surfaces in validate mode.
95
+ const findings = [...plan.findings];
92
96
  if (installed.length === 0) {
93
97
  findings.push({
94
98
  ruleId: "blocks.sidecar.installed",
@@ -154,6 +158,18 @@ async function buildInstallPlan(options, mode) {
154
158
  stopConditions.push(`Missing safe install anchor ${targetFile.anchor} in ${targetFile.path}.`);
155
159
  }
156
160
  }
161
+ // Seam guard: the factory declares providesCapabilities, but Vise owns the
162
+ // completeness id vocabulary. Unknown ids warn (never block) — they simply
163
+ // can't satisfy a completeness gap in `vise check`.
164
+ const providesCapabilities = providesCapabilitiesFor(block);
165
+ const knownCapabilityIds = completenessCapabilityIds();
166
+ const unknownCapabilityIds = providesCapabilities.filter((id) => !knownCapabilityIds.has(id));
167
+ const findings = unknownCapabilityIds.map((id) => ({
168
+ ruleId: "blocks.providesCapabilities.known",
169
+ severity: "warning",
170
+ message: `Block ${block.blockId} declares providesCapabilities id "${id}", which is not in this Vise's completeness checklist vocabulary.`,
171
+ recommendation: "Unknown ids never satisfy completeness gaps. Align the Block Factory contract with Vise's capability catalog (vise owns the id space) or upgrade Vise.",
172
+ }));
157
173
  return {
158
174
  status: stopConditions.length > 0 ? "needs-review" : "ready",
159
175
  mode,
@@ -168,11 +184,19 @@ async function buildInstallPlan(options, mode) {
168
184
  packageSource: options.packageSource,
169
185
  packageChange,
170
186
  targetFiles,
187
+ providesCapabilities,
188
+ findings,
171
189
  sensors: sensors.map((sensor) => ({ name: sensor.name, command: sensor.command, source: sensor.source })),
172
190
  stopConditions,
173
191
  sidecarPath: path.join("sp-vise", "blocks.json"),
174
192
  };
175
193
  }
194
+ function providesCapabilitiesFor(block) {
195
+ if (!Array.isArray(block.providesCapabilities)) {
196
+ return [];
197
+ }
198
+ return [...new Set(block.providesCapabilities.filter((id) => typeof id === "string" && id.trim() !== ""))];
199
+ }
176
200
  async function loadRegistry(registryPath) {
177
201
  if (!registryPath) {
178
202
  throw new Error("blocks command requires --registry <path>.");
@@ -286,7 +310,7 @@ async function writeBlocksSidecar(repoPath, plan, packageSource, filesTouched) {
286
310
  const sidecarPath = path.join(repoPath, "sp-vise", "blocks.json");
287
311
  const existing = await readSidecar(repoPath);
288
312
  const sidecar = existing ?? {
289
- schemaVersion: "2026-06-04.vise-blocks-sidecar.v1",
313
+ schemaVersion: blocksSidecarSchemaVersion,
290
314
  viseVersion: packageVersion,
291
315
  generatedAt: new Date().toISOString(),
292
316
  installed: [],
@@ -296,11 +320,14 @@ async function writeBlocksSidecar(repoPath, plan, packageSource, filesTouched) {
296
320
  blockVersion: plan.block.version,
297
321
  platform: plan.registryPlatform,
298
322
  packageSource,
323
+ dependencyName: plan.package.dependencyName,
324
+ providesCapabilities: plan.providesCapabilities,
299
325
  filesTouched,
300
326
  designContractDigest: designContract?.digest,
301
327
  sdkFactsVersion: plan.block.version,
302
328
  validationStatus: "installed",
303
329
  };
330
+ sidecar.schemaVersion = blocksSidecarSchemaVersion;
304
331
  sidecar.viseVersion = packageVersion;
305
332
  sidecar.generatedAt = new Date().toISOString();
306
333
  sidecar.installed = [...sidecar.installed.filter((item) => item.blockId !== plan.block.id), entry];
@@ -317,6 +344,72 @@ async function readSidecar(repoPath) {
317
344
  return null;
318
345
  }
319
346
  }
347
+ /**
348
+ * Registry-free evidence that installed blocks deliver completeness capabilities.
349
+ *
350
+ * `vise check` cannot assume registry access, so a sidecar entry's
351
+ * `providesCapabilities` only counts when the install is still locally valid:
352
+ * the block's manifest dependency is declared in the project's package manifest
353
+ * AND every recorded `filesTouched` path still exists. On drift (file removed,
354
+ * package gone, pre-providesCapabilities sidecar) the entry contributes nothing
355
+ * and the capability reverts to the normal completeness gap.
356
+ */
357
+ export async function installedBlockProvidedCapabilities(repoPath) {
358
+ const repoRoot = path.resolve(repoPath);
359
+ const sidecar = await readSidecar(repoRoot);
360
+ if (!sidecar || !Array.isArray(sidecar.installed)) {
361
+ return [];
362
+ }
363
+ const provided = [];
364
+ for (const entry of sidecar.installed) {
365
+ const capabilities = Array.isArray(entry.providesCapabilities)
366
+ ? entry.providesCapabilities.filter((id) => typeof id === "string" && id.trim() !== "")
367
+ : [];
368
+ if (capabilities.length === 0) {
369
+ continue;
370
+ }
371
+ if (!(await installedEntryLocallyValid(repoRoot, entry))) {
372
+ continue;
373
+ }
374
+ for (const capabilityId of capabilities) {
375
+ provided.push({ capabilityId, blockId: entry.blockId });
376
+ }
377
+ }
378
+ return provided;
379
+ }
380
+ async function installedEntryLocallyValid(repoRoot, entry) {
381
+ if (!(await blockDependencyDeclared(repoRoot, entry))) {
382
+ return false;
383
+ }
384
+ for (const touched of entry.filesTouched ?? []) {
385
+ if (typeof touched !== "string" || touched.trim() === "") {
386
+ return false;
387
+ }
388
+ if (!(await exists(path.join(repoRoot, touched)))) {
389
+ return false;
390
+ }
391
+ }
392
+ return true;
393
+ }
394
+ async function blockDependencyDeclared(repoRoot, entry) {
395
+ const dependencyName = entry.dependencyName;
396
+ if (!dependencyName) {
397
+ // Pre-2026-06-10 sidecars carry no package evidence; fail closed so the gap stays.
398
+ return false;
399
+ }
400
+ if (entry.platform === "flutter") {
401
+ const source = await readFile(path.join(repoRoot, "pubspec.yaml"), "utf8").catch(() => "");
402
+ return new RegExp(`\\b${escapeRegExp(dependencyName)}\\s*:`).test(source);
403
+ }
404
+ try {
405
+ const packageJson = await readPackageJson(path.join(repoRoot, "package.json"));
406
+ const devDependencies = packageJson.devDependencies;
407
+ return Boolean(packageJson.dependencies?.[dependencyName] ?? devDependencies?.[dependencyName]);
408
+ }
409
+ catch {
410
+ return false;
411
+ }
412
+ }
320
413
  function dependencyValue(root, packageInfo, packageSource, platform) {
321
414
  if (!packageSource || packageSource === "npm") {
322
415
  return platform === "flutter" ? "^0.1.0" : "^0.1.0";
@@ -2,12 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, optionalCapabilityChecklist, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
5
+ import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, optionalCapabilityChecklist, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
6
6
  import { getOutcomeDefinition, hasAnswer, planContextFor, resolveOutcome, } from "../outcomes.js";
7
7
  import { contractRuleCandidatesForPublicId, hasMultipleContractRuleCandidates, productExpectationBindingForSensor, productExpectationTitle, publicProductRuleId, } from "../productExpectations.js";
8
8
  import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
9
9
  import { packageVersion } from "../version.js";
10
10
  import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
11
+ import { installedBlockProvidedCapabilities } from "./blocks.js";
11
12
  import { inspectProject, validateSetup } from "./project.js";
12
13
  import { readCreativeSelection } from "./creative.js";
13
14
  import { assessUxHarness, buildExperienceReport, buildUxHarness, readUxHarness, } from "./uxHarness.js";
@@ -737,7 +738,17 @@ export async function checkCompliance(repoPath) {
737
738
  // Completeness-gap: capabilities that are neither present nor validly opted-out require an explicit decision
738
739
  // (build it, or place // vise: scope-omit <id> — <reason>). The scope-omit escape hatch keeps this
739
740
  // FP-free because any capability can be excluded with a recorded reason. Failure to assess is silently ignored.
740
- const completeness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
741
+ const sourceCompleteness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
742
+ // Installed Block Factory blocks deliver capabilities inside their packages,
743
+ // which the customer-source scan cannot see. Overlay block evidence for
744
+ // still-missing checklist items, but only from sidecar entries that pass
745
+ // registry-free local validation (manifest dependency declared + every
746
+ // filesTouched path intact — see installedBlockProvidedCapabilities). On
747
+ // local drift the evidence is withheld and the normal gap returns.
748
+ const blockProvided = sourceCompleteness && sourceCompleteness.missing.length > 0
749
+ ? await installedBlockProvidedCapabilities(repoRoot).catch(() => [])
750
+ : [];
751
+ const completeness = sourceCompleteness ? applyBlockProvidedCompleteness(sourceCompleteness, blockProvided) : undefined;
741
752
  const hasCompletenessGap = (completeness?.missing.length ?? 0) > 0;
742
753
  const selectedOptionalCapabilities = (await assessProjectSelectedOptionalCapabilities(inspection.effectiveRoot, compliance.outcome, compliance.selected_optional_capabilities ?? []).catch(() => null)) ?? undefined;
743
754
  const hasSelectedOptionalFailures = ((selectedOptionalCapabilities?.failed.length ?? 0) > 0) || ((selectedOptionalCapabilities?.unknown.length ?? 0) > 0);
@@ -1337,7 +1348,7 @@ function sourcePathCandidatesFromString(value) {
1337
1348
  if (looksLikeSourcePath(trimmed)) {
1338
1349
  candidates.add(trimmed);
1339
1350
  }
1340
- const pathPattern = /(?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.(?:ts|tsx|js|jsx|kt|java|dart|swift|xml|gradle|kts|json|ya?ml|env|plist|pbxproj|podspec)|\b(?:Podfile|Package\.swift|pubspec\.yaml|package\.json|AndroidManifest\.xml)\b/g;
1351
+ const pathPattern = /(?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.(?:tsx|ts|jsx|json|js|kts|kt|java|dart|swift|xml|gradle|ya?ml|env|plist|pbxproj|podspec)|\b(?:Podfile|Package\.swift|pubspec\.yaml|package\.json|AndroidManifest\.xml)\b/g;
1341
1352
  for (const match of trimmed.matchAll(pathPattern)) {
1342
1353
  candidates.add(match[0]);
1343
1354
  }
@@ -1346,7 +1357,7 @@ function sourcePathCandidatesFromString(value) {
1346
1357
  function looksLikeSourcePath(value) {
1347
1358
  return (/[/\\]/.test(value) ||
1348
1359
  /^(?:Podfile|Package\.swift|pubspec\.yaml|package\.json|AndroidManifest\.xml)$/.test(value) ||
1349
- /\.(?:ts|tsx|js|jsx|kt|java|dart|swift|xml|gradle|kts|json|ya?ml|env|plist|pbxproj|podspec)(?::\d+)?$/i.test(value));
1360
+ /\.(?:tsx|ts|jsx|json|js|kts|kt|java|dart|swift|xml|gradle|ya?ml|env|plist|pbxproj|podspec)(?::\d+)?$/i.test(value));
1350
1361
  }
1351
1362
  function cleanEvidencePathCandidate(value) {
1352
1363
  return value