@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 +23 -0
- package/dist/server.js +20 -12
- package/dist/tools/blocks.js +81 -8
- package/dist/tools/compliance.js +92 -9
- package/dist/tools/project.js +116 -29
- package/dist/tools/sensors.js +102 -36
- package/package.json +2 -2
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
530
|
-
|
|
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",
|
|
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
|
|
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: ">=
|
|
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",
|
package/dist/tools/blocks.js
CHANGED
|
@@ -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-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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,
|
|
514
|
+
lines.splice(insertIndex, 0, ...dependencyLines);
|
|
442
515
|
return `${lines.join("\n").trimEnd()}\n`;
|
|
443
516
|
}
|
|
444
517
|
async function readPackageJson(filePath) {
|
package/dist/tools/compliance.js
CHANGED
|
@@ -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,
|
|
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
|
|
935
|
-
const findings =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/dist/tools/project.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
4225
|
-
"react-native": /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\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/,
|
package/dist/tools/sensors.js
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
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
|
|
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:
|
|
114
|
-
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
|
-
|
|
158
|
+
const text = chunk.toString("utf8");
|
|
159
|
+
captureOutput(stdout, text);
|
|
160
|
+
observeEnvironmentOutput(text);
|
|
120
161
|
});
|
|
121
162
|
child.stderr?.on("data", (chunk) => {
|
|
122
|
-
|
|
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:
|
|
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 &&
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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:
|
|
176
|
-
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
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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.
|
|
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": ">=
|
|
25
|
+
"node": ">=20"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|