@amityco/social-plus-vise 1.5.0 → 1.7.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 +30 -0
- package/README.md +31 -49
- package/dist/flow.js +6 -0
- package/dist/outcomes.js +10 -0
- package/dist/server.js +56 -11
- package/dist/tools/blocks.js +1 -1
- package/dist/tools/compliance.js +226 -18
- package/dist/tools/internalWorkspace.js +44 -0
- package/dist/tools/project.js +205 -0
- package/dist/tools/sdkFacts.js +233 -7
- package/docs/vise-how-it-helps.html +52 -308
- package/package.json +1 -1
- package/packages/intelligence/catalog/catalog.schema.json +6 -0
- package/packages/intelligence/catalog/experience-objects.json +11 -11
- package/rules/channel-archive.yaml +136 -0
- package/rules/for-you-feed.yaml +110 -0
- package/rules/message-search.yaml +136 -0
- package/sdk-surface/android.json +1305 -23
- package/sdk-surface/flutter.json +2542 -2582
- package/sdk-surface/ios.json +1182 -166
- package/sdk-surface/manifest.json +33 -33
- package/sdk-surface/models.android.json +14 -4
- package/sdk-surface/models.flutter.json +2 -2
- package/sdk-surface/models.ios.json +2 -2
- package/sdk-surface/models.typescript.json +2892 -452
- package/sdk-surface/typescript.json +8337 -2443
- package/skills/social-plus-vise/SKILL.md +20 -0
package/dist/tools/compliance.js
CHANGED
|
@@ -416,6 +416,22 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
416
416
|
"The gate still blocks until every id above is answered; pass --allow-unresolved-intake only for retrospective/harness initialization.",
|
|
417
417
|
};
|
|
418
418
|
}
|
|
419
|
+
const previousCompliance = await readJsonIfExists(compliancePath(repoRoot));
|
|
420
|
+
let engagementSwitch;
|
|
421
|
+
if (previousCompliance && previousCompliance.outcome !== outcome) {
|
|
422
|
+
const finalCheck = await checkCompliance(repoPath).catch(() => undefined);
|
|
423
|
+
if (finalCheck?.status === "green" && !options.supersedeEngagement) {
|
|
424
|
+
return {
|
|
425
|
+
status: "needs-engagement-supersede-confirmation",
|
|
426
|
+
exitCode: 7,
|
|
427
|
+
from: previousCompliance.outcome,
|
|
428
|
+
to: outcome,
|
|
429
|
+
reason: `The current engagement (outcome "${previousCompliance.outcome}") is green — finished, verified work. Initializing "${outcome}" replaces its contract, and its completeness will no longer be checked (it moves to the engagement ledger; see \`vise status\` previousEngagements and \`vise check --all-engagements\`).`,
|
|
430
|
+
instruction: `Re-run with --supersede-engagement to proceed. The green final state will be recorded in sp-vise/engagements/${previousCompliance.outcome}/ first.`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
engagementSwitch = await recordEngagementOnSwitch(repoRoot, previousCompliance, outcome, finalCheck);
|
|
434
|
+
}
|
|
419
435
|
const compliance = {
|
|
420
436
|
schema_version: schemaVersion,
|
|
421
437
|
vise_version: packageVersion,
|
|
@@ -468,6 +484,17 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
468
484
|
if (engagement && engagement.scope.outcomes.length > 0 && !engagement.scope.outcomes.includes(outcome)) {
|
|
469
485
|
warnings.push(`Outcome "${outcome}" is not in the engagement scope (${engagement.scope.outcomes.join(", ")}). Compliance was still initialized; extend the scope in engagement.json or re-run vise engagement init.`);
|
|
470
486
|
}
|
|
487
|
+
const preExistingFindingFiles = [
|
|
488
|
+
...new Set(checkSnapshot.rules
|
|
489
|
+
.filter((rule) => BASELINE_NON_GREEN.has(rule.status) && rule.finding?.file)
|
|
490
|
+
.map((rule) => rule.finding?.file)),
|
|
491
|
+
];
|
|
492
|
+
const brownfieldHint = !options.baselinePlanned && preExistingFindingFiles.length > 0 && !(await readBaseline(repoRoot))
|
|
493
|
+
? {
|
|
494
|
+
pre_existing_finding_files: preExistingFindingFiles.slice(0, 5),
|
|
495
|
+
hint: "This repo already contains social.plus integration with non-green findings that predate this engagement. Record `vise baseline .` NOW (before writing any code) so `vise check --new-only` gates only findings you introduce. The outcome's completeness checklist is never baselined — the requested feature still has to be built. Never record a baseline mid-build: it would swallow your own findings.",
|
|
496
|
+
}
|
|
497
|
+
: undefined;
|
|
471
498
|
return {
|
|
472
499
|
status: "initialized",
|
|
473
500
|
sidecar: complianceDirName,
|
|
@@ -480,6 +507,8 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
480
507
|
...(compliance.design_contract && { design_contract: compliance.design_contract }),
|
|
481
508
|
...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
|
|
482
509
|
...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
|
|
510
|
+
...(brownfieldHint ? { brownfield: brownfieldHint } : {}),
|
|
511
|
+
...(engagementSwitch ? { engagement_switch: engagementSwitch } : {}),
|
|
483
512
|
intake: {
|
|
484
513
|
status: intake.status,
|
|
485
514
|
remainingBlocking: intake.remainingBlocking,
|
|
@@ -862,7 +891,7 @@ async function ruleFreshness(rule) {
|
|
|
862
891
|
}
|
|
863
892
|
export async function checkCompliance(repoPath, options = {}) {
|
|
864
893
|
const repoRoot = path.resolve(repoPath);
|
|
865
|
-
const compliance = await readCompliance(repoRoot);
|
|
894
|
+
const compliance = options.contract ?? (await readCompliance(repoRoot));
|
|
866
895
|
const rules = await rulesById();
|
|
867
896
|
const drift = contractDrift(compliance, rules);
|
|
868
897
|
if (drift.length > 0) {
|
|
@@ -996,6 +1025,27 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
996
1025
|
const exactMatch = attestation.rule_digest === ref.rule_digest;
|
|
997
1026
|
const grandfathered = !exactMatch && isAttestationGrandfathered(rule, attestation);
|
|
998
1027
|
if (exactMatch || grandfathered) {
|
|
1028
|
+
if (attestation.outcome !== undefined &&
|
|
1029
|
+
attestation.outcome !== compliance.outcome &&
|
|
1030
|
+
!(attestation.file_scoped === true && isFileScopableRule(rule))) {
|
|
1031
|
+
const crossEngagementStatus = rule.advisory ? "advisory" : rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail";
|
|
1032
|
+
results.push({
|
|
1033
|
+
...checkRuleIdentity(rule.id),
|
|
1034
|
+
title: rule.title,
|
|
1035
|
+
severity: rule.severity,
|
|
1036
|
+
status: crossEngagementStatus,
|
|
1037
|
+
reason: rule.advisory
|
|
1038
|
+
? "Advisory: informational only — does not affect compliance status."
|
|
1039
|
+
: `Attestation was recorded during a different engagement (outcome "${attestation.outcome}", attested ${attestation.attested_at}); the current engagement is "${compliance.outcome}". Re-verify the claim still holds for the code this engagement adds, then re-attest.`,
|
|
1040
|
+
finding,
|
|
1041
|
+
recommendation: finding?.recommendation,
|
|
1042
|
+
rationale: rule.rationale,
|
|
1043
|
+
current_rule: ruleSummary(rule),
|
|
1044
|
+
stale_engagement_attestation: { outcome: attestation.outcome, attested_at: attestation.attested_at },
|
|
1045
|
+
...(crossEngagementStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
1046
|
+
});
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
999
1049
|
const sourceFingerprintStatus = await checkSourceFingerprints(repoRoot, inspection.effectiveRoot, attestation.source_fingerprints ?? []);
|
|
1000
1050
|
const staleFingerprints = sourceFingerprintStatus.filter((item) => item.status !== "match");
|
|
1001
1051
|
if (staleFingerprints.length > 0) {
|
|
@@ -1153,22 +1203,23 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
1153
1203
|
result.baselined = true;
|
|
1154
1204
|
}
|
|
1155
1205
|
}
|
|
1156
|
-
const
|
|
1157
|
-
const baselinedSelected = new Set(baselineFile.selected_optional);
|
|
1206
|
+
const legacyDeliverableEntries = (baselineFile.completeness_missing?.length ?? 0) + (baselineFile.selected_optional?.length ?? 0);
|
|
1158
1207
|
gatedResults = results.filter((result) => !result.baselined);
|
|
1159
|
-
gatedMissing = (completeness?.missing ?? []).filter((item) => !baselinedMissing.has(item.id));
|
|
1160
|
-
gatedSelectedFailed = (selectedOptionalCapabilities?.failed ?? []).filter((item) => !baselinedSelected.has(item.id));
|
|
1161
|
-
gatedSelectedUnknown = (selectedOptionalCapabilities?.unknown ?? []).filter((id) => !baselinedSelected.has(id));
|
|
1162
1208
|
baselineReport = {
|
|
1163
1209
|
present: true,
|
|
1164
1210
|
applied: true,
|
|
1165
1211
|
baselined_at: baselineFile.baselined_at,
|
|
1166
1212
|
excluded: {
|
|
1167
1213
|
rules: results.filter((result) => result.baselined).length,
|
|
1168
|
-
completeness:
|
|
1169
|
-
selected_optional:
|
|
1170
|
-
((selectedOptionalCapabilities?.unknown.length ?? 0) - gatedSelectedUnknown.length),
|
|
1214
|
+
completeness: 0,
|
|
1215
|
+
selected_optional: 0,
|
|
1171
1216
|
},
|
|
1217
|
+
...(legacyDeliverableEntries > 0
|
|
1218
|
+
? {
|
|
1219
|
+
ignored_legacy_deliverable_entries: legacyDeliverableEntries,
|
|
1220
|
+
ignored_legacy_note: "This baseline predates the deliverables fix and recorded completeness/selected-capability entries; they are ignored — the engagement's deliverables always gate. Re-run `vise baseline` to refresh the file.",
|
|
1221
|
+
}
|
|
1222
|
+
: {}),
|
|
1172
1223
|
residual_caveat: "Baseline excludes pre-existing findings keyed by rule+file (count-based). A NEW violation of an already-baselined rule in the SAME file can be masked at this granularity — review the touched files directly for a precise per-change gate.",
|
|
1173
1224
|
};
|
|
1174
1225
|
}
|
|
@@ -1668,6 +1719,29 @@ export async function statusCompliance(repoPath, options = {}) {
|
|
|
1668
1719
|
reviewer_assignment: engagement.reviewer_assignment,
|
|
1669
1720
|
},
|
|
1670
1721
|
}),
|
|
1722
|
+
...(await previousEngagementsSection(repoRoot, check.outcome)),
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
async function previousEngagementsSection(repoRoot, activeOutcome) {
|
|
1726
|
+
const dir = engagementsDir(repoRoot);
|
|
1727
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1728
|
+
const previous = [];
|
|
1729
|
+
for (const entry of entries) {
|
|
1730
|
+
if (!entry.isDirectory() || entry.name === activeOutcome) {
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
const record = await readJsonIfExists(path.join(dir, entry.name, "record.json"));
|
|
1734
|
+
if (record) {
|
|
1735
|
+
previous.push(record);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
if (previous.length === 0) {
|
|
1739
|
+
return {};
|
|
1740
|
+
}
|
|
1741
|
+
previous.sort((a, b) => String(a.outcome).localeCompare(String(b.outcome)));
|
|
1742
|
+
return {
|
|
1743
|
+
previousEngagements: previous,
|
|
1744
|
+
previousEngagements_note: "Superseded engagements' last recorded state (latest per outcome; snapshots, not live verdicts). Their completeness is NOT checked by the active contract — to re-verify one today, re-run `vise init` with its original request and `vise check`. On-demand re-verification lands with `vise check --all-engagements` (docs/ENGAGEMENTS_DESIGN.md, B2).",
|
|
1671
1745
|
};
|
|
1672
1746
|
}
|
|
1673
1747
|
async function applicableRules(outcome, platforms) {
|
|
@@ -2300,6 +2374,9 @@ export const FILE_SCOPABLE_SENSOR_SUFFIXES = new Set([
|
|
|
2300
2374
|
"live-collection.api-mismatch",
|
|
2301
2375
|
"story.live-collection",
|
|
2302
2376
|
"search.live-collection",
|
|
2377
|
+
"message-search.live-collection",
|
|
2378
|
+
"channel-archive.live-collection",
|
|
2379
|
+
"for-you-feed.live-collection",
|
|
2303
2380
|
"notification-tray.live-collection",
|
|
2304
2381
|
"event.live-collection",
|
|
2305
2382
|
"blocked-users.live-collection",
|
|
@@ -2432,6 +2509,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
|
|
|
2432
2509
|
evidence,
|
|
2433
2510
|
source_fingerprints: sourceFingerprints,
|
|
2434
2511
|
...(fileScoped ? { file_scoped: true } : {}),
|
|
2512
|
+
outcome: compliance.outcome,
|
|
2435
2513
|
rationale_for_attestation: rationale,
|
|
2436
2514
|
attested_at: new Date().toISOString(),
|
|
2437
2515
|
};
|
|
@@ -2788,6 +2866,8 @@ function sidecarReadme(compliance) {
|
|
|
2788
2866
|
"",
|
|
2789
2867
|
"Attestations include source fingerprints; `vise check` marks them stale if the cited files change.",
|
|
2790
2868
|
"",
|
|
2869
|
+
"`engagements/<outcome>/` (when present) is the engagement ledger: each superseded engagement's final check, contract, and intake, recorded when a `vise init` switched to a different outcome. `vise status` lists them under `previousEngagements`; re-verify with `vise check --engagement <outcome>` or `--all-engagements`.",
|
|
2870
|
+
"",
|
|
2791
2871
|
].join("\n");
|
|
2792
2872
|
}
|
|
2793
2873
|
function attestationsDir(repoRoot) {
|
|
@@ -2824,6 +2904,129 @@ function deriveGate(flags) {
|
|
|
2824
2904
|
return { status: "runtime-proof-waived", exitCode: flags.allowProofWaiver ? 0 : 9 };
|
|
2825
2905
|
return { status: "green", exitCode: 0 };
|
|
2826
2906
|
}
|
|
2907
|
+
async function recordEngagementOnSwitch(repoRoot, previous, nextOutcome, finalCheck) {
|
|
2908
|
+
const previousIntake = await readJsonIfExists(path.join(sidecarDir(repoRoot), "intake.json"));
|
|
2909
|
+
const dir = path.join(engagementsDir(repoRoot), String(previous.outcome));
|
|
2910
|
+
await rm(dir, { recursive: true, force: true });
|
|
2911
|
+
await mkdir(dir, { recursive: true });
|
|
2912
|
+
const verdict = finalCheck
|
|
2913
|
+
? { status: finalCheck.status, exitCode: finalCheck.exitCode }
|
|
2914
|
+
: { status: "unrecorded", exitCode: null, note: "The final check could not run at switch time; contract and intake are still recorded." };
|
|
2915
|
+
const record = {
|
|
2916
|
+
outcome: previous.outcome,
|
|
2917
|
+
...(typeof previousIntake?.request === "string" ? { request: previousIntake.request } : {}),
|
|
2918
|
+
recorded_at: new Date().toISOString(),
|
|
2919
|
+
reason: `superseded by ${nextOutcome} init`,
|
|
2920
|
+
ruleset_digest: previous.ruleset_digest,
|
|
2921
|
+
verdict,
|
|
2922
|
+
};
|
|
2923
|
+
await writeJson(path.join(dir, "record.json"), record);
|
|
2924
|
+
if (finalCheck) {
|
|
2925
|
+
await writeJson(path.join(dir, "final-check.json"), finalCheck);
|
|
2926
|
+
}
|
|
2927
|
+
await writeJson(path.join(dir, "contract.json"), previous);
|
|
2928
|
+
if (previousIntake) {
|
|
2929
|
+
await writeJson(path.join(dir, "intake.json"), previousIntake);
|
|
2930
|
+
}
|
|
2931
|
+
return {
|
|
2932
|
+
from: previous.outcome,
|
|
2933
|
+
to: nextOutcome,
|
|
2934
|
+
recorded: path.join(complianceDirName, "engagements", String(previous.outcome)),
|
|
2935
|
+
last_verdict: verdict,
|
|
2936
|
+
note: "The previous engagement's final state is recorded in the ledger; its completeness is no longer checked by this contract. `vise status` lists it under previousEngagements. On-demand re-verification (`vise check --all-engagements`) lands with B2 (docs/ENGAGEMENTS_DESIGN.md).",
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
function engagementsDir(repoRoot) {
|
|
2940
|
+
return path.join(sidecarDir(repoRoot), "engagements");
|
|
2941
|
+
}
|
|
2942
|
+
async function reVerdictOne(repoPath, repoRoot, outcome) {
|
|
2943
|
+
const dir = path.join(engagementsDir(repoRoot), outcome);
|
|
2944
|
+
const record = await readJsonIfExists(path.join(dir, "record.json"));
|
|
2945
|
+
const recorded = await readJsonIfExists(path.join(dir, "contract.json"));
|
|
2946
|
+
if (!record || !recorded) {
|
|
2947
|
+
const available = (await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []))
|
|
2948
|
+
.filter((entry) => entry.isDirectory())
|
|
2949
|
+
.map((entry) => entry.name);
|
|
2950
|
+
throw new Error(`No recorded engagement for outcome "${outcome}".${available.length > 0 ? ` Recorded engagements: ${available.join(", ")}.` : " The engagement ledger is empty (it is written when an init switches away from an outcome)."}`);
|
|
2951
|
+
}
|
|
2952
|
+
const inspection = await inspectProject(repoRoot, recorded.surface?.path === "." ? undefined : recorded.surface?.path);
|
|
2953
|
+
const platforms = Array.from(new Set([...inspection.platforms, ...(recorded.surface?.platforms ?? [])]));
|
|
2954
|
+
const rules = await applicableRules(recorded.outcome, platforms.length > 0 ? platforms : ["unknown"]);
|
|
2955
|
+
const synthetic = {
|
|
2956
|
+
...recorded,
|
|
2957
|
+
ruleset_digest: digestJson(rules.map(ruleRef)),
|
|
2958
|
+
rules: rules.map(ruleRefForFile),
|
|
2959
|
+
generated_at: new Date().toISOString(),
|
|
2960
|
+
};
|
|
2961
|
+
const check = await checkCompliance(repoPath, { contract: synthetic });
|
|
2962
|
+
return { record, check, ruleset_upgraded: recorded.ruleset_digest !== synthetic.ruleset_digest };
|
|
2963
|
+
}
|
|
2964
|
+
export async function checkEngagementReVerdict(repoPath, outcome) {
|
|
2965
|
+
const repoRoot = path.resolve(repoPath);
|
|
2966
|
+
const { record, check, ruleset_upgraded } = await reVerdictOne(repoPath, repoRoot, outcome);
|
|
2967
|
+
const drifted = check.status !== "green";
|
|
2968
|
+
return {
|
|
2969
|
+
mode: "re-verdict",
|
|
2970
|
+
engagement: outcome,
|
|
2971
|
+
...(typeof record.request === "string" ? { request: record.request } : {}),
|
|
2972
|
+
recorded_at: record.recorded_at,
|
|
2973
|
+
recorded_verdict: record.verdict,
|
|
2974
|
+
ruleset_upgraded,
|
|
2975
|
+
status: check.status,
|
|
2976
|
+
exitCode: drifted ? 11 : 0,
|
|
2977
|
+
summary: check.summary,
|
|
2978
|
+
...(check.completeness ? { completeness: check.completeness } : {}),
|
|
2979
|
+
rules: check.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status)),
|
|
2980
|
+
note: "Re-verdict of a recorded engagement against the current ruleset and code. It does not change the active engagement's gate; exit 11 signals engagement drift.",
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
export async function checkAllEngagements(repoPath, options = {}) {
|
|
2984
|
+
const repoRoot = path.resolve(repoPath);
|
|
2985
|
+
const active = await checkCompliance(repoPath, options);
|
|
2986
|
+
const activeNonGreenKeys = new Set(active.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status)).map((rule) => baselineKeyFor(rule)));
|
|
2987
|
+
const entries = await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []);
|
|
2988
|
+
const engagements = [];
|
|
2989
|
+
let anyDrift = false;
|
|
2990
|
+
for (const entry of entries) {
|
|
2991
|
+
if (!entry.isDirectory() || entry.name === active.outcome) {
|
|
2992
|
+
continue;
|
|
2993
|
+
}
|
|
2994
|
+
const { record, check, ruleset_upgraded } = await reVerdictOne(repoPath, repoRoot, entry.name);
|
|
2995
|
+
const nonGreen = check.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status));
|
|
2996
|
+
const gating = nonGreen.filter((rule) => !activeNonGreenKeys.has(baselineKeyFor(rule)));
|
|
2997
|
+
const shared = nonGreen.length - gating.length;
|
|
2998
|
+
const completenessMissing = check.completeness?.missing ?? [];
|
|
2999
|
+
const drifted = gating.length > 0 || completenessMissing.length > 0 || check.status === "contract-drift";
|
|
3000
|
+
anyDrift = anyDrift || drifted;
|
|
3001
|
+
engagements.push({
|
|
3002
|
+
outcome: entry.name,
|
|
3003
|
+
...(typeof record.request === "string" ? { request: record.request } : {}),
|
|
3004
|
+
recorded_at: record.recorded_at,
|
|
3005
|
+
recorded_verdict: record.verdict,
|
|
3006
|
+
ruleset_upgraded,
|
|
3007
|
+
status: drifted ? check.status : "green",
|
|
3008
|
+
drift: drifted,
|
|
3009
|
+
...(shared > 0 ? { shared_with_active: shared } : {}),
|
|
3010
|
+
...(gating.length > 0 ? { gating } : {}),
|
|
3011
|
+
...(completenessMissing.length > 0 ? { completeness_missing: completenessMissing.map((item) => item.id) } : {}),
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
engagements.sort((a, b) => String(a.outcome).localeCompare(String(b.outcome)));
|
|
3015
|
+
const activeGreen = active.exitCode === 0;
|
|
3016
|
+
return {
|
|
3017
|
+
...active,
|
|
3018
|
+
...(activeGreen && anyDrift
|
|
3019
|
+
? {
|
|
3020
|
+
status: "engagement-drift",
|
|
3021
|
+
exitCode: 11,
|
|
3022
|
+
active_status: active.status,
|
|
3023
|
+
active_exitCode: active.exitCode,
|
|
3024
|
+
}
|
|
3025
|
+
: {}),
|
|
3026
|
+
engagements,
|
|
3027
|
+
engagements_note: "Re-verdicts of recorded engagements against the current ruleset and code. Findings already gating the active engagement are counted once there (shared_with_active). Exit 11 = active engagement green but a recorded engagement drifted.",
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
2827
3030
|
export async function recordBaseline(repoPath) {
|
|
2828
3031
|
const repoRoot = path.resolve(repoPath);
|
|
2829
3032
|
const compliance = await readCompliance(repoRoot);
|
|
@@ -2838,19 +3041,18 @@ export async function recordBaseline(repoPath) {
|
|
|
2838
3041
|
const key = baselineKeyFor(result);
|
|
2839
3042
|
findings[key] = (findings[key] ?? 0) + 1;
|
|
2840
3043
|
}
|
|
2841
|
-
const
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
];
|
|
3044
|
+
const deliverables_not_baselined = {
|
|
3045
|
+
completeness_missing: (check.completeness?.missing ?? []).length,
|
|
3046
|
+
selected_optional: (check.selectedOptionalCapabilities?.failed ?? []).length + (check.selectedOptionalCapabilities?.unknown ?? []).length,
|
|
3047
|
+
};
|
|
2846
3048
|
const baseline = {
|
|
2847
3049
|
baselined_at: new Date().toISOString(),
|
|
2848
3050
|
vise_version: packageVersion,
|
|
2849
3051
|
outcome: compliance.outcome,
|
|
2850
3052
|
ruleset_digest: compliance.ruleset_digest,
|
|
2851
3053
|
findings,
|
|
2852
|
-
completeness_missing,
|
|
2853
|
-
selected_optional,
|
|
3054
|
+
completeness_missing: [],
|
|
3055
|
+
selected_optional: [],
|
|
2854
3056
|
};
|
|
2855
3057
|
await writeJson(baselinePath(repoRoot), baseline);
|
|
2856
3058
|
return {
|
|
@@ -2859,9 +3061,15 @@ export async function recordBaseline(repoPath) {
|
|
|
2859
3061
|
outcome: baseline.outcome,
|
|
2860
3062
|
recorded: {
|
|
2861
3063
|
findings: Object.values(findings).reduce((a, b) => a + b, 0),
|
|
2862
|
-
completeness_missing: completeness_missing.length,
|
|
2863
|
-
selected_optional: selected_optional.length,
|
|
2864
3064
|
},
|
|
3065
|
+
...(deliverables_not_baselined.completeness_missing + deliverables_not_baselined.selected_optional > 0
|
|
3066
|
+
? {
|
|
3067
|
+
deliverables_not_baselined: {
|
|
3068
|
+
...deliverables_not_baselined,
|
|
3069
|
+
note: "The outcome's completeness checklist and selected optional capabilities are this engagement's deliverables — they are never baselined and still gate `vise check --new-only`. Use a completeness opt-out for capabilities the product genuinely does not want.",
|
|
3070
|
+
},
|
|
3071
|
+
}
|
|
3072
|
+
: {}),
|
|
2865
3073
|
nextStep: "Implement your change, then run `vise check --new-only` to gate only on findings introduced since this baseline. Re-run `vise baseline` after the contract or outcome changes.",
|
|
2866
3074
|
};
|
|
2867
3075
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const INTERNAL_PACKAGE_NAMES = {
|
|
4
|
+
"@amityco/social-plus-vise": "the Vise source tree",
|
|
5
|
+
"social-plus-blocks": "the social.plus blocks product tree",
|
|
6
|
+
};
|
|
7
|
+
export async function detectInternalWorkspace(repoPath) {
|
|
8
|
+
const root = path.resolve(repoPath);
|
|
9
|
+
const markers = [];
|
|
10
|
+
const rootName = await packageName(root);
|
|
11
|
+
if (rootName && INTERNAL_PACKAGE_NAMES[rootName]) {
|
|
12
|
+
markers.push(`package.json name "${rootName}" — this is ${INTERNAL_PACKAGE_NAMES[rootName]}`);
|
|
13
|
+
}
|
|
14
|
+
const workspaceFile = path.join(root, "pnpm-workspace.yaml");
|
|
15
|
+
if (await readFile(workspaceFile, "utf8").then(() => true, () => false)) {
|
|
16
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const memberName = await packageName(path.join(root, entry.name));
|
|
22
|
+
if (memberName && INTERNAL_PACKAGE_NAMES[memberName]) {
|
|
23
|
+
markers.push(`workspace member ${entry.name}/ is "${memberName}" — this is ${INTERNAL_PACKAGE_NAMES[memberName]}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { isInternalWorkspace: markers.length > 0, markers };
|
|
28
|
+
}
|
|
29
|
+
export function internalWorkspaceWarning(detection) {
|
|
30
|
+
return {
|
|
31
|
+
detectedMarkers: detection.markers,
|
|
32
|
+
reason: "This target looks like the social.plus Vise / social.plus blocks development workspace, not a customer project.",
|
|
33
|
+
guidance: "Customer-integration commands write sp-vise/ state into a customer repo. For SDK surface facts while packaging blocks, use `vise sdk-facts` (projectless and read-only). To run the customer flow against a workspace fixture, point Vise at the fixture directory itself.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function packageName(directory) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
|
|
39
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/tools/project.js
CHANGED
|
@@ -472,6 +472,9 @@ async function validateAndroid(root) {
|
|
|
472
472
|
findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
|
|
473
473
|
findings.push(...validateStories(root, "android", sourceContent));
|
|
474
474
|
findings.push(...validateSearch(root, "android", sourceContent));
|
|
475
|
+
findings.push(...validateMessageSearch(root, "android", sourceContent));
|
|
476
|
+
findings.push(...validateChannelArchive(root, "android", sourceContent));
|
|
477
|
+
findings.push(...validateForYouFeed(root, "android", sourceContent));
|
|
475
478
|
findings.push(...validateNotificationTray(root, "android", sourceContent));
|
|
476
479
|
findings.push(...validateMembershipList(root, "android", sourceContent));
|
|
477
480
|
findings.push(...validateEvents(root, "android", sourceContent));
|
|
@@ -601,6 +604,8 @@ async function validateFlutter(root) {
|
|
|
601
604
|
findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
|
|
602
605
|
findings.push(...validateStories(root, "flutter", dartContent));
|
|
603
606
|
findings.push(...validateSearch(root, "flutter", dartContent));
|
|
607
|
+
findings.push(...validateMessageSearch(root, "flutter", dartContent));
|
|
608
|
+
findings.push(...validateChannelArchive(root, "flutter", dartContent));
|
|
604
609
|
findings.push(...validateMembershipList(root, "flutter", dartContent));
|
|
605
610
|
findings.push(...validateBlockedUsers(root, "flutter", dartContent));
|
|
606
611
|
findings.push(...validatePollVoteStatusGuard(root, "flutter", dartContent));
|
|
@@ -732,6 +737,9 @@ async function validateTypeScript(root, platform) {
|
|
|
732
737
|
findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
|
|
733
738
|
findings.push(...validateStories(root, platform, sourceContent));
|
|
734
739
|
findings.push(...validateSearch(root, platform, sourceContent));
|
|
740
|
+
findings.push(...validateMessageSearch(root, platform, sourceContent));
|
|
741
|
+
findings.push(...validateChannelArchive(root, platform, sourceContent));
|
|
742
|
+
findings.push(...validateForYouFeed(root, platform, sourceContent));
|
|
735
743
|
findings.push(...validateNotificationTray(root, platform, sourceContent));
|
|
736
744
|
findings.push(...validateMembershipList(root, platform, sourceContent));
|
|
737
745
|
findings.push(...validateEvents(root, platform, sourceContent));
|
|
@@ -1862,6 +1870,200 @@ function validateSearch(root, platform, sourceContent) {
|
|
|
1862
1870
|
}
|
|
1863
1871
|
return findings;
|
|
1864
1872
|
}
|
|
1873
|
+
function validateMessageSearch(root, platform, sourceContent) {
|
|
1874
|
+
const findings = [];
|
|
1875
|
+
const ruleId = `${platform}.message-search.live-collection`;
|
|
1876
|
+
const MESSAGE_SEARCH_QUERY_PATTERNS = [
|
|
1877
|
+
/\bsearchMessages?\b/,
|
|
1878
|
+
/\bAmityMessageSearchQuery\b/,
|
|
1879
|
+
/\bAmityMessageSearchOptions\b/,
|
|
1880
|
+
/\bSearchMessageBuilder\b/,
|
|
1881
|
+
/\bSearchMessageLiveCollection\b/,
|
|
1882
|
+
];
|
|
1883
|
+
const LIST_UI_PATTERNS = [
|
|
1884
|
+
/\bmap\s*\(/,
|
|
1885
|
+
/\bFlatList\b/,
|
|
1886
|
+
/\bListView\b/,
|
|
1887
|
+
/\bRecyclerView\b/,
|
|
1888
|
+
/\bLazyColumn\b/,
|
|
1889
|
+
/\bLazyRow\b/,
|
|
1890
|
+
/\bLazyVerticalGrid\b/,
|
|
1891
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1892
|
+
/\bForEach\b/,
|
|
1893
|
+
/\bList\s*\(/,
|
|
1894
|
+
/\bsubmitList\b/,
|
|
1895
|
+
/\btableView\b/,
|
|
1896
|
+
/\bcollectionView\b/
|
|
1897
|
+
];
|
|
1898
|
+
const REACTIVE_MARKERS = [
|
|
1899
|
+
/\bgetLiveCollection\b/,
|
|
1900
|
+
/\bobserve\b/,
|
|
1901
|
+
/\bobserveOnce\b/,
|
|
1902
|
+
/\blisten\b/,
|
|
1903
|
+
/\bonData\b/,
|
|
1904
|
+
/\bStreamBuilder\b/,
|
|
1905
|
+
/\bLiveData\b/,
|
|
1906
|
+
/\bMutableLiveData\b/,
|
|
1907
|
+
/\bMediatorLiveData\b/,
|
|
1908
|
+
/collectAsState/,
|
|
1909
|
+
/observeAsState/,
|
|
1910
|
+
/Amity\w*Flow\b/,
|
|
1911
|
+
/\bPagingData\b/,
|
|
1912
|
+
/LazyPagingItems/,
|
|
1913
|
+
/collectAsLazyPagingItems/,
|
|
1914
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1915
|
+
/\$snapshots\b/,
|
|
1916
|
+
/\bPagingController\b/,
|
|
1917
|
+
/\bgetPagingData\b/,
|
|
1918
|
+
/\bsearchMessages?\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1919
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1920
|
+
];
|
|
1921
|
+
for (const [file, content] of sourceContent) {
|
|
1922
|
+
const rel = relativeFile(root, file);
|
|
1923
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1924
|
+
continue;
|
|
1925
|
+
const hasSearchQuery = MESSAGE_SEARCH_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1926
|
+
if (hasSearchQuery) {
|
|
1927
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1928
|
+
if (hasListUI) {
|
|
1929
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1930
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1931
|
+
if (!hasReactivity) {
|
|
1932
|
+
findings.push(finding(ruleId, "warning", `A message search (searchMessage/searchMessages) renders a list but no reactive/paginated markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render message-search results from the reactive, paginated Search Message LiveCollection so the list live-updates and pages. Use the live callback (TS/RN: searchMessage(params, callback)), observe the collection (iOS searchMessages(options:) + .observe / Android AmityMessageSearchQuery PagingData), or wire a PagingController with SearchMessageLiveCollection (Flutter). If a one-shot single-page search is truly intended, add comment // vise: one-shot query."));
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return findings;
|
|
1938
|
+
}
|
|
1939
|
+
function validateChannelArchive(root, platform, sourceContent) {
|
|
1940
|
+
const findings = [];
|
|
1941
|
+
const ruleId = `${platform}.channel-archive.live-collection`;
|
|
1942
|
+
const ARCHIVE_QUERY_PATTERNS = [
|
|
1943
|
+
/\bgetArchivedChannels\b/,
|
|
1944
|
+
/\bgetArchivedChannelIds\b/,
|
|
1945
|
+
];
|
|
1946
|
+
const LIST_UI_PATTERNS = [
|
|
1947
|
+
/\bmap\s*\(/,
|
|
1948
|
+
/\bFlatList\b/,
|
|
1949
|
+
/\bListView\b/,
|
|
1950
|
+
/\bRecyclerView\b/,
|
|
1951
|
+
/\bLazyColumn\b/,
|
|
1952
|
+
/\bLazyRow\b/,
|
|
1953
|
+
/\bLazyVerticalGrid\b/,
|
|
1954
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1955
|
+
/\bForEach\b/,
|
|
1956
|
+
/\bList\s*\(/,
|
|
1957
|
+
/\bsubmitList\b/,
|
|
1958
|
+
/\btableView\b/,
|
|
1959
|
+
/\bcollectionView\b/
|
|
1960
|
+
];
|
|
1961
|
+
const REACTIVE_MARKERS = [
|
|
1962
|
+
/\bgetLiveCollection\b/,
|
|
1963
|
+
/\bobserve\b/,
|
|
1964
|
+
/\bobserveOnce\b/,
|
|
1965
|
+
/\blisten\b/,
|
|
1966
|
+
/\bonData\b/,
|
|
1967
|
+
/\bStreamBuilder\b/,
|
|
1968
|
+
/\bLiveData\b/,
|
|
1969
|
+
/\bMutableLiveData\b/,
|
|
1970
|
+
/\bMediatorLiveData\b/,
|
|
1971
|
+
/collectAsState/,
|
|
1972
|
+
/observeAsState/,
|
|
1973
|
+
/Amity\w*Flow\b/,
|
|
1974
|
+
/\bPagingData\b/,
|
|
1975
|
+
/LazyPagingItems/,
|
|
1976
|
+
/collectAsLazyPagingItems/,
|
|
1977
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1978
|
+
/\$snapshots\b/,
|
|
1979
|
+
/\bPagingController\b/,
|
|
1980
|
+
/\bgetPagingData\b/,
|
|
1981
|
+
/\bgetArchivedChannels\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1982
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1983
|
+
];
|
|
1984
|
+
for (const [file, content] of sourceContent) {
|
|
1985
|
+
const rel = relativeFile(root, file);
|
|
1986
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1987
|
+
continue;
|
|
1988
|
+
const hasArchiveQuery = ARCHIVE_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1989
|
+
if (hasArchiveQuery) {
|
|
1990
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1991
|
+
if (hasListUI) {
|
|
1992
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1993
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1994
|
+
if (!hasReactivity) {
|
|
1995
|
+
findings.push(finding(ruleId, "warning", `An archived-channels view (getArchivedChannels / getArchivedChannelIds) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the archived-channels view from the getArchivedChannels LiveCollection (subscribe via its callback / observe and re-render on updates, dispose on unmount) rather than a one-shot getArchivedChannelIds fetch, so the list updates as channels are archived or unarchived. If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return findings;
|
|
2001
|
+
}
|
|
2002
|
+
function validateForYouFeed(root, platform, sourceContent) {
|
|
2003
|
+
const findings = [];
|
|
2004
|
+
if (platform === "flutter")
|
|
2005
|
+
return findings;
|
|
2006
|
+
const ruleId = `${platform}.for-you-feed.live-collection`;
|
|
2007
|
+
const FORYOU_QUERY_PATTERNS = [
|
|
2008
|
+
/\bgetForYouFeed\b/,
|
|
2009
|
+
];
|
|
2010
|
+
const LIST_UI_PATTERNS = [
|
|
2011
|
+
/\bmap\s*\(/,
|
|
2012
|
+
/\bFlatList\b/,
|
|
2013
|
+
/\bListView\b/,
|
|
2014
|
+
/\bRecyclerView\b/,
|
|
2015
|
+
/\bLazyColumn\b/,
|
|
2016
|
+
/\bLazyRow\b/,
|
|
2017
|
+
/\bLazyVerticalGrid\b/,
|
|
2018
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
2019
|
+
/\bForEach\b/,
|
|
2020
|
+
/\bList\s*\(/,
|
|
2021
|
+
/\bsubmitList\b/,
|
|
2022
|
+
/\btableView\b/,
|
|
2023
|
+
/\bcollectionView\b/
|
|
2024
|
+
];
|
|
2025
|
+
const REACTIVE_MARKERS = [
|
|
2026
|
+
/\bgetLiveCollection\b/,
|
|
2027
|
+
/\bobserve\b/,
|
|
2028
|
+
/\bobserveOnce\b/,
|
|
2029
|
+
/\blisten\b/,
|
|
2030
|
+
/\bonData\b/,
|
|
2031
|
+
/\bStreamBuilder\b/,
|
|
2032
|
+
/\bLiveData\b/,
|
|
2033
|
+
/\bMutableLiveData\b/,
|
|
2034
|
+
/\bMediatorLiveData\b/,
|
|
2035
|
+
/collectAsState/,
|
|
2036
|
+
/observeAsState/,
|
|
2037
|
+
/Amity\w*Flow\b/,
|
|
2038
|
+
/\bPagingData\b/,
|
|
2039
|
+
/LazyPagingItems/,
|
|
2040
|
+
/collectAsLazyPagingItems/,
|
|
2041
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
2042
|
+
/\$snapshots\b/,
|
|
2043
|
+
/\bPagingController\b/,
|
|
2044
|
+
/\bgetPagingData\b/,
|
|
2045
|
+
/\bgetForYouFeed\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
2046
|
+
/\bgetForYouFeed\s*\(\s*(?:\([^)]*\)\s*=>|\w+\s*=>|function\b|async\b)/,
|
|
2047
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
2048
|
+
];
|
|
2049
|
+
for (const [file, content] of sourceContent) {
|
|
2050
|
+
const rel = relativeFile(root, file);
|
|
2051
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2052
|
+
continue;
|
|
2053
|
+
const hasForYouQuery = FORYOU_QUERY_PATTERNS.some((p) => p.test(content));
|
|
2054
|
+
if (hasForYouQuery) {
|
|
2055
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
2056
|
+
if (hasListUI) {
|
|
2057
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
2058
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
2059
|
+
if (!hasReactivity) {
|
|
2060
|
+
findings.push(finding(ruleId, "warning", `A For-You feed (getForYouFeed) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Query the personalized feed via getForYouFeed and OBSERVE it as a live collection (subscribe / .observe / PagingData — never a one-shot fetch-and-map), so the feed updates as ranking and freshness change. Gate on getForYouFeedSetting and, when disabled, fall back to the global feed rather than rendering empty. If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return findings;
|
|
2066
|
+
}
|
|
1865
2067
|
function validateNotificationTray(root, platform, sourceContent) {
|
|
1866
2068
|
const findings = [];
|
|
1867
2069
|
const ruleId = `${platform}.notification-tray.live-collection`;
|
|
@@ -3162,6 +3364,9 @@ async function validateIos(root) {
|
|
|
3162
3364
|
findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
|
|
3163
3365
|
findings.push(...validateStories(root, "ios", swiftContent));
|
|
3164
3366
|
findings.push(...validateSearch(root, "ios", swiftContent));
|
|
3367
|
+
findings.push(...validateMessageSearch(root, "ios", swiftContent));
|
|
3368
|
+
findings.push(...validateChannelArchive(root, "ios", swiftContent));
|
|
3369
|
+
findings.push(...validateForYouFeed(root, "ios", swiftContent));
|
|
3165
3370
|
findings.push(...validateNotificationTray(root, "ios", swiftContent));
|
|
3166
3371
|
findings.push(...validateMembershipList(root, "ios", swiftContent));
|
|
3167
3372
|
findings.push(...validateEvents(root, "ios", swiftContent));
|