@amityco/social-plus-vise 1.2.0 → 1.4.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 +75 -2
- package/README.md +23 -10
- package/dist/capabilities.js +35 -4
- package/dist/entryState.js +71 -0
- package/dist/experience.js +70 -0
- package/dist/explore.js +51 -0
- package/dist/flow.js +382 -0
- package/dist/humanFormat.js +251 -0
- package/dist/intake.js +117 -0
- package/dist/intelligence/placement.js +2 -1
- package/dist/outcomes.js +141 -42
- package/dist/productExpectations.js +15 -0
- package/dist/requestReadiness.js +99 -0
- package/dist/server.js +675 -56
- package/dist/sidecar.js +5 -0
- package/dist/solutionPath.js +2 -2
- package/dist/tools/compliance.js +1014 -133
- package/dist/tools/creative.js +14 -12
- package/dist/tools/debug.js +83 -26
- package/dist/tools/design.js +344 -14
- package/dist/tools/experienceCompiler.js +1 -1
- package/dist/tools/experienceSensors.js +1 -1
- package/dist/tools/harness.js +13 -0
- package/dist/tools/integration.js +263 -90
- package/dist/tools/learning.js +1 -3
- package/dist/tools/project.js +987 -72
- package/dist/tools/sdkFacts.js +8 -1
- package/dist/tools/smoke.js +134 -0
- package/dist/tools/uxHarness.js +54 -6
- package/dist/uikitCustomization.js +22 -6
- package/dist/version.js +7 -3
- package/package.json +1 -1
- package/packages/intelligence/catalog/catalog.schema.json +1 -1
- package/packages/intelligence/catalog/experience-objects.json +2 -2
- package/packages/intelligence/catalog/variants.json +24 -0
- package/rules/event.yaml +3 -0
- package/rules/feed.yaml +251 -0
- package/rules/invitation.yaml +4 -0
- package/rules/live-data.yaml +110 -0
- package/rules/notification-tray.yaml +4 -0
- package/rules/poll.yaml +5 -0
- package/rules/sdk-lifecycle.yaml +559 -0
- package/rules/search.yaml +5 -0
- package/rules/security.yaml +12 -0
- package/rules/story.yaml +5 -0
- package/rules/user-blocking.yaml +5 -0
- package/skills/social-plus-vise/SKILL.md +163 -15
package/dist/tools/compliance.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { sidecarDir } from "../sidecar.js";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds,
|
|
6
|
-
import {
|
|
7
|
+
import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
|
|
8
|
+
import { CLASSIFY_ORDER, getOutcomeDefinition, planContextFor, resolveOutcome, } from "../outcomes.js";
|
|
7
9
|
import { contractRuleCandidatesForPublicId, hasMultipleContractRuleCandidates, productExpectationBindingForSensor, productExpectationTitle, publicProductRuleId, } from "../productExpectations.js";
|
|
8
10
|
import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
|
|
11
|
+
import { assembleIntake } from "../intake.js";
|
|
12
|
+
import { clarifyDimensions, clarifyGuidance } from "../requestReadiness.js";
|
|
13
|
+
import { recommendSolutionPath } from "../solutionPath.js";
|
|
14
|
+
import { recommendUIKitCustomization } from "../uikitCustomization.js";
|
|
9
15
|
import { packageVersion } from "../version.js";
|
|
10
16
|
import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
|
|
11
17
|
import { installedBlockProvidedCapabilities } from "./blocks.js";
|
|
@@ -210,7 +216,7 @@ function stringArray(value) {
|
|
|
210
216
|
return value.filter((item) => typeof item === "string" && item.trim() !== "");
|
|
211
217
|
}
|
|
212
218
|
const validTiers = new Set(["free", "pro", "partner"]);
|
|
213
|
-
const validOutcomes = new Set([
|
|
219
|
+
const validOutcomes = new Set([...CLASSIFY_ORDER, "unknown"]);
|
|
214
220
|
export async function initEngagement(args) {
|
|
215
221
|
const repoRoot = path.resolve(args.repoPath);
|
|
216
222
|
const engagementFile = engagementPath(repoRoot);
|
|
@@ -289,6 +295,13 @@ function designReviewFor(repoRoot, contract, answers) {
|
|
|
289
295
|
requiredForDesignConformance: true,
|
|
290
296
|
};
|
|
291
297
|
if (confirmation === "accepted") {
|
|
298
|
+
if (base.previewPath && !existsSync(base.previewPath)) {
|
|
299
|
+
return {
|
|
300
|
+
...base,
|
|
301
|
+
status: "needs-confirmation",
|
|
302
|
+
nextStep: `Cannot accept the design contract: its preview (${base.previewPath}) does not exist, so it could not have been reviewed. Run \`vise design preview\` to regenerate it, review it, then re-confirm with --answer ${DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID}=yes.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
292
305
|
return {
|
|
293
306
|
...base,
|
|
294
307
|
status: "accepted",
|
|
@@ -327,6 +340,26 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
327
340
|
const creativeSelection = storedCreativeSelection && creativeSelectionAppliesToRequest(storedCreativeSelection, request)
|
|
328
341
|
? storedCreativeSelection
|
|
329
342
|
: undefined;
|
|
343
|
+
if (options.allowUnresolvedIntake !== true) {
|
|
344
|
+
const platformDetected = inspection.platforms.length > 0;
|
|
345
|
+
const initSolutionPath = recommendSolutionPath(request, answers);
|
|
346
|
+
const initUikit = recommendUIKitCustomization({ request, answers, platform, solutionPath: initSolutionPath });
|
|
347
|
+
const clarify = clarifyGuidance(clarifyDimensions({
|
|
348
|
+
outcome,
|
|
349
|
+
platformDetected,
|
|
350
|
+
uikitGuidedUnknown: platformDetected && outcome === "unknown" && Boolean(initUikit),
|
|
351
|
+
creativeSelected: Boolean(creativeSelection),
|
|
352
|
+
}));
|
|
353
|
+
if (clarify) {
|
|
354
|
+
return {
|
|
355
|
+
status: "needs-clarification",
|
|
356
|
+
exitCode: 7,
|
|
357
|
+
outcome,
|
|
358
|
+
clarify,
|
|
359
|
+
nextStep: `${clarify.nextStep} (Or pass --allow-unresolved-intake for retrospective/harness initialization.)`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
330
363
|
const uxHarness = creativeSelection
|
|
331
364
|
? await buildUxHarness({ repoPath: repoRoot, surfacePath, selection: creativeSelection, write: true })
|
|
332
365
|
: undefined;
|
|
@@ -341,16 +374,39 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
341
374
|
capabilityAvailability,
|
|
342
375
|
allowUnresolvedIntake: options.allowUnresolvedIntake === true,
|
|
343
376
|
});
|
|
377
|
+
const intakeWarnings = validateIntakeAnswers(answers, {
|
|
378
|
+
request,
|
|
379
|
+
outcome,
|
|
380
|
+
platforms: inspection.platforms,
|
|
381
|
+
designSignals: inspection.designSignals,
|
|
382
|
+
});
|
|
344
383
|
if (intake.remainingBlocking > 0 && !intake.acknowledged_unresolved_blocking) {
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
.map((question) => question.id);
|
|
384
|
+
const blockingQuestions = intake.questions.filter((question) => question.blocksImplementationWhenMissing);
|
|
385
|
+
const blockingIds = blockingQuestions.map((question) => question.id);
|
|
348
386
|
const answerExample = blockingIds.map((id) => `${id}=<value>`).join(" --answer ");
|
|
349
387
|
return {
|
|
350
388
|
status: "needs-clarification",
|
|
351
389
|
exitCode: 7,
|
|
352
390
|
outcome,
|
|
353
391
|
intake,
|
|
392
|
+
decisionChecklist: blockingQuestions.map((question) => ({
|
|
393
|
+
id: question.id,
|
|
394
|
+
question: question.question,
|
|
395
|
+
why: question.why,
|
|
396
|
+
options: question.options,
|
|
397
|
+
})),
|
|
398
|
+
unattended: {
|
|
399
|
+
status: "blocked",
|
|
400
|
+
mode: "handoff-required",
|
|
401
|
+
reason: "Blocking intake answers are required before this surface can be initialized.",
|
|
402
|
+
instruction: "If the user is not available, stop and return the blocking intake questions. Do not invent scope, route, identity, region, design, or capability answers.",
|
|
403
|
+
allowedActions: ["read-only inspect/plan output", "collect exact --answer values from the user"],
|
|
404
|
+
resumeWhen: [
|
|
405
|
+
"every blocking intake id has an explicit user-provided answer",
|
|
406
|
+
"init is re-run with one repeated --answer id=value flag per blocking id",
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
...(intakeWarnings.length > 0 && { warnings: intakeWarnings }),
|
|
354
410
|
nextStep: "Run `vise plan` and surface the blocking intake questions to the customer. " +
|
|
355
411
|
`Then re-run with their answers (one --answer per id): vise init --request <request> --answer ${answerExample}. ` +
|
|
356
412
|
"Each --answer takes a single id=value; repeat the flag per id (do not comma-join). " +
|
|
@@ -393,6 +449,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
393
449
|
});
|
|
394
450
|
await writeJson(path.join(sidecarDir(repoRoot), "inspection.json"), inspection);
|
|
395
451
|
await writeFile(path.join(sidecarDir(repoRoot), "README.md"), sidecarReadme(compliance), "utf8");
|
|
452
|
+
const runtimeSmokeTemplate = await writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome);
|
|
396
453
|
const checkSnapshot = await checkCompliance(repoPath);
|
|
397
454
|
await writeJson(path.join(sidecarDir(repoRoot), "findings.json"), {
|
|
398
455
|
snapshot_at: compliance.generated_at,
|
|
@@ -401,8 +458,10 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
401
458
|
status: checkSnapshot.status,
|
|
402
459
|
summary: checkSnapshot.summary,
|
|
403
460
|
rules: checkSnapshot.rules,
|
|
461
|
+
...(checkSnapshot.completeness ? { completeness: checkSnapshot.completeness } : {}),
|
|
462
|
+
...(checkSnapshot.selectedOptionalCapabilities ? { selectedOptionalCapabilities: checkSnapshot.selectedOptionalCapabilities } : {}),
|
|
404
463
|
});
|
|
405
|
-
const warnings = [];
|
|
464
|
+
const warnings = [...intakeWarnings];
|
|
406
465
|
if (engagement && engagement.scope.outcomes.length > 0 && !engagement.scope.outcomes.includes(outcome)) {
|
|
407
466
|
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.`);
|
|
408
467
|
}
|
|
@@ -417,6 +476,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
417
476
|
...(uxHarness ? { ux_harness: uxHarnessSummary(uxHarness) } : {}),
|
|
418
477
|
...(compliance.design_contract && { design_contract: compliance.design_contract }),
|
|
419
478
|
...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
|
|
479
|
+
...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
|
|
420
480
|
intake: {
|
|
421
481
|
status: intake.status,
|
|
422
482
|
remainingBlocking: intake.remainingBlocking,
|
|
@@ -425,7 +485,127 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
425
485
|
answers: intake.answers,
|
|
426
486
|
},
|
|
427
487
|
...(warnings.length > 0 && { warnings }),
|
|
428
|
-
nextStep:
|
|
488
|
+
nextStep: runtimeSmokeTemplate
|
|
489
|
+
? "Run vise check, implement until rules pass, then cold-launch the app, capture VISE_SMOKE logs, run vise smoke, and rerun vise check before completing the surface."
|
|
490
|
+
: "Run vise check, then implement until rules pass deterministically or are attested.",
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function runtimeSmokeSurfaceForOutcome(outcome) {
|
|
494
|
+
switch (outcome) {
|
|
495
|
+
case "add-community":
|
|
496
|
+
return {
|
|
497
|
+
id: "community",
|
|
498
|
+
expect: "populated",
|
|
499
|
+
launch: "Cold-launch into the product route that lists/selects the current community target.",
|
|
500
|
+
};
|
|
501
|
+
case "add-feed":
|
|
502
|
+
return {
|
|
503
|
+
id: "feed",
|
|
504
|
+
expect: "populated",
|
|
505
|
+
launch: "Cold-launch into the product route that mounts the selected feed/post collection.",
|
|
506
|
+
};
|
|
507
|
+
case "add-comments":
|
|
508
|
+
return {
|
|
509
|
+
id: "comments",
|
|
510
|
+
expect: "populated",
|
|
511
|
+
launch: "Cold-launch into a product post/thread route with a visible parent post and comment collection.",
|
|
512
|
+
};
|
|
513
|
+
case "add-chat":
|
|
514
|
+
return {
|
|
515
|
+
id: "chat",
|
|
516
|
+
expect: "populated",
|
|
517
|
+
launch: "Cold-launch into the product inbox or selected channel route with readable messages.",
|
|
518
|
+
};
|
|
519
|
+
case "add-follow":
|
|
520
|
+
return {
|
|
521
|
+
id: "profile",
|
|
522
|
+
expect: "populated",
|
|
523
|
+
launch: "Cold-launch into a product profile/member route with relationship data visible.",
|
|
524
|
+
};
|
|
525
|
+
case "add-notifications":
|
|
526
|
+
return {
|
|
527
|
+
id: "notifications",
|
|
528
|
+
expect: "resolved",
|
|
529
|
+
launch: "Cold-launch into the product notification tray/settings route.",
|
|
530
|
+
};
|
|
531
|
+
default:
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function runtimeSmokeCaptureRecipe(platform) {
|
|
536
|
+
const normalized = platform.toLowerCase();
|
|
537
|
+
if (normalized === "android") {
|
|
538
|
+
return {
|
|
539
|
+
log: "adb logcat",
|
|
540
|
+
steps: [
|
|
541
|
+
"adb logcat -c",
|
|
542
|
+
"Cold-launch the app into the social route and wait until the visible collection resolves.",
|
|
543
|
+
"adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log",
|
|
544
|
+
"vise smoke . --log sp-vise/evidence/runtime-smoke.log",
|
|
545
|
+
],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (normalized === "ios") {
|
|
549
|
+
return {
|
|
550
|
+
log: "simctl unified log",
|
|
551
|
+
steps: [
|
|
552
|
+
"xcrun simctl launch --terminate-running-process <device> <bundle-id>",
|
|
553
|
+
"Wait until the visible collection resolves.",
|
|
554
|
+
"xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log",
|
|
555
|
+
"vise smoke . --log sp-vise/evidence/runtime-smoke.log",
|
|
556
|
+
],
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (normalized === "flutter") {
|
|
560
|
+
return {
|
|
561
|
+
log: "flutter run logs or adb logcat",
|
|
562
|
+
steps: [
|
|
563
|
+
"Use an ignored define file such as .dart_tool/vise-runtime-defines.env for runtime values.",
|
|
564
|
+
"Cold-launch the app and wait until the collection resolves.",
|
|
565
|
+
"Capture debugPrint VISE_SMOKE lines from flutter run logs or adb logcat into sp-vise/evidence/runtime-smoke.log.",
|
|
566
|
+
"vise smoke . --log sp-vise/evidence/runtime-smoke.log",
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (normalized === "react-native") {
|
|
571
|
+
return {
|
|
572
|
+
log: "Metro/device logs",
|
|
573
|
+
steps: [
|
|
574
|
+
"Cold-launch the app and wait until the collection resolves.",
|
|
575
|
+
"Capture console.info VISE_SMOKE lines from Metro or adb logcat into sp-vise/evidence/runtime-smoke.log.",
|
|
576
|
+
"vise smoke . --log sp-vise/evidence/runtime-smoke.log",
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
log: "browser or app runtime log",
|
|
582
|
+
steps: [
|
|
583
|
+
"Cold-load the route that mounts the collection surface.",
|
|
584
|
+
"Capture console.info VISE_SMOKE lines into sp-vise/evidence/runtime-smoke.log.",
|
|
585
|
+
"vise smoke . --log sp-vise/evidence/runtime-smoke.log",
|
|
586
|
+
],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
async function writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome) {
|
|
590
|
+
const surface = runtimeSmokeSurfaceForOutcome(outcome);
|
|
591
|
+
if (!surface)
|
|
592
|
+
return null;
|
|
593
|
+
const smokePath = path.join(sidecarDir(repoRoot), "smoke.json");
|
|
594
|
+
if (existsSync(smokePath)) {
|
|
595
|
+
return { status: "existing", path: "sp-vise/smoke.json", surface: surface.id };
|
|
596
|
+
}
|
|
597
|
+
const platformName = platform || "unknown";
|
|
598
|
+
await writeJson(smokePath, {
|
|
599
|
+
platform: platformName,
|
|
600
|
+
surfaces: [surface],
|
|
601
|
+
capture: runtimeSmokeCaptureRecipe(platformName),
|
|
602
|
+
});
|
|
603
|
+
return {
|
|
604
|
+
status: "created",
|
|
605
|
+
path: "sp-vise/smoke.json",
|
|
606
|
+
surface: surface.id,
|
|
607
|
+
expect: surface.expect,
|
|
608
|
+
capture: runtimeSmokeCaptureRecipe(platformName),
|
|
429
609
|
};
|
|
430
610
|
}
|
|
431
611
|
function intakeAuditFor(args) {
|
|
@@ -438,79 +618,61 @@ function intakeAuditFor(args) {
|
|
|
438
618
|
designSignals: args.designSignals,
|
|
439
619
|
answers: args.answers,
|
|
440
620
|
});
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
if (args.designReview?.status === "rejected" && !hasAnswer(ctx.answers, "design_source")) {
|
|
453
|
-
questions.push({
|
|
454
|
-
id: "design_source",
|
|
455
|
-
question: "What design source should replace the rejected preview/contract?",
|
|
456
|
-
why: "The current design preview was rejected, so Vise must not use it. Provide a prototype, screenshot, theme/token file, or explicitly say to proceed without a design-conformance claim.",
|
|
457
|
-
required: true,
|
|
458
|
-
blocksImplementationWhenMissing: true,
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
|
|
462
|
-
questions.push({
|
|
463
|
-
id: "design_source",
|
|
464
|
-
question: "Where are the app's design tokens, theme, or reusable UI components defined?",
|
|
465
|
-
why: "The user asked to match the existing design, and no local design source was detected.",
|
|
466
|
-
required: true,
|
|
467
|
-
blocksImplementationWhenMissing: true,
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
if (ctx.mentionsDesign && ctx.designSignals.length > 0 && !hasAnswer(ctx.answers, "confirm_design_source")) {
|
|
471
|
-
questions.push({
|
|
472
|
-
id: "confirm_design_source",
|
|
473
|
-
question: `Should the social UI use the detected design source(s): ${ctx.designSignals.map((signal) => signal.file).join(", ")}?`,
|
|
474
|
-
why: "Vise found likely design evidence, but the user or host agent should confirm it is the right source before UI edits.",
|
|
475
|
-
required: true,
|
|
476
|
-
blocksImplementationWhenMissing: false,
|
|
477
|
-
options: ["yes", "use another source"],
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
if (args.designBrief &&
|
|
481
|
-
args.designReview?.status === "accepted" &&
|
|
482
|
-
(args.outcome === "add-feed" || args.outcome === "add-chat") &&
|
|
483
|
-
!args.designBrief.roles.some((role) => role.role === "primaryAction") &&
|
|
484
|
-
!hasAnswer(ctx.answers, "primary_action_token")) {
|
|
485
|
-
questions.push({
|
|
486
|
-
id: "primary_action_token",
|
|
487
|
-
question: "Which design token (or color value) should be used as the primary action color? No primary-action token was confidently identified in the design contract.",
|
|
488
|
-
why: "A primary action colour is needed for interactive elements (composer button, own-message bubble). Without a confident token, the agent must guess or omit it.",
|
|
489
|
-
required: false,
|
|
490
|
-
blocksImplementationWhenMissing: false,
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
const availableOptionalIds = availableOptionalCapabilityIds(args.capabilityAvailability);
|
|
494
|
-
const optionalChoices = args.outcome === "add-feed" ? optionalCapabilityChecklist(args.outcome, availableOptionalIds) : [];
|
|
495
|
-
if (args.outcome === "add-feed" && optionalChoices.length > 0 && !hasAnswer(ctx.answers, "feed_optional_capabilities")) {
|
|
496
|
-
questions.push({
|
|
497
|
-
id: "feed_optional_capabilities",
|
|
498
|
-
question: `Which optional feed capabilities should be in scope on ${platform}: ${optionalChoices.map((capability) => capability.id).join(", ")}, or none?`,
|
|
499
|
-
why: "These capabilities are useful for full feeds, but should become enforceable only after the customer explicitly opts in. Vise only lists capabilities that appear available in the platform SDK surface; unsupported capabilities must be treated as out of scope or confirmed from docs.",
|
|
500
|
-
required: false,
|
|
501
|
-
blocksImplementationWhenMissing: false,
|
|
502
|
-
options: ["none", ...optionalChoices.map((capability) => capability.id)],
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
|
|
621
|
+
const outcomeQuestions = getOutcomeDefinition(args.outcome).intakeQuestions(ctx);
|
|
622
|
+
const { questions, remainingBlocking, status } = assembleIntake({
|
|
623
|
+
ctx,
|
|
624
|
+
outcomeQuestions,
|
|
625
|
+
outcome: args.outcome,
|
|
626
|
+
designBrief: args.designBrief,
|
|
627
|
+
capabilityAvailability: args.capabilityAvailability,
|
|
628
|
+
designReview: args.designReview,
|
|
629
|
+
});
|
|
506
630
|
return {
|
|
507
|
-
status
|
|
631
|
+
status,
|
|
508
632
|
questions,
|
|
509
633
|
answers: args.answers,
|
|
510
634
|
remainingBlocking,
|
|
511
635
|
acknowledged_unresolved_blocking: args.allowUnresolvedIntake && remainingBlocking > 0,
|
|
512
636
|
};
|
|
513
637
|
}
|
|
638
|
+
const APPENDED_INTAKE_QUESTION_IDS = new Set([
|
|
639
|
+
DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID,
|
|
640
|
+
"design_source",
|
|
641
|
+
"confirm_design_source",
|
|
642
|
+
"primary_action_token",
|
|
643
|
+
"feed_optional_capabilities",
|
|
644
|
+
]);
|
|
645
|
+
function validateIntakeAnswers(answers, ctx) {
|
|
646
|
+
const warnings = [];
|
|
647
|
+
const catalogCtx = planContextFor({
|
|
648
|
+
request: ctx.request,
|
|
649
|
+
outcome: ctx.outcome,
|
|
650
|
+
platform: preferredPlatform(ctx.platforms),
|
|
651
|
+
platforms: ctx.platforms,
|
|
652
|
+
designSignals: ctx.designSignals,
|
|
653
|
+
answers: {},
|
|
654
|
+
});
|
|
655
|
+
const catalog = getOutcomeDefinition(ctx.outcome).intakeQuestions(catalogCtx);
|
|
656
|
+
const byId = new Map(catalog.map((question) => [question.id, question]));
|
|
657
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
658
|
+
const question = byId.get(key);
|
|
659
|
+
if (!question && !APPENDED_INTAKE_QUESTION_IDS.has(key)) {
|
|
660
|
+
warnings.push(`Intake answer "${key}" was ignored — it does not match any intake question for outcome "${ctx.outcome}". Run \`vise plan\` to see the valid question ids.`);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const optionsAreTokens = Array.isArray(question?.options) &&
|
|
664
|
+
question.options.length > 0 &&
|
|
665
|
+
question.options.every((option) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(option));
|
|
666
|
+
if (question && optionsAreTokens) {
|
|
667
|
+
const tokens = String(value).split(",").map((token) => token.trim()).filter(Boolean);
|
|
668
|
+
const invalid = tokens.filter((token) => !question.options.includes(token));
|
|
669
|
+
if (invalid.length > 0) {
|
|
670
|
+
warnings.push(`Intake answer "${key}=${value}" includes value(s) outside the allowed options for "${key}" (${question.options.join(", ")}). The unexpected value(s) [${invalid.join(", ")}] may be ignored; re-run with a valid option.`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return warnings;
|
|
675
|
+
}
|
|
514
676
|
function creativeSelectionSummary(selection) {
|
|
515
677
|
return {
|
|
516
678
|
status: selection.status,
|
|
@@ -680,7 +842,7 @@ async function ruleFreshness(rule) {
|
|
|
680
842
|
...(verifiedAgainst && { verified_against: verifiedAgainst }),
|
|
681
843
|
};
|
|
682
844
|
}
|
|
683
|
-
export async function checkCompliance(repoPath) {
|
|
845
|
+
export async function checkCompliance(repoPath, options = {}) {
|
|
684
846
|
const repoRoot = path.resolve(repoPath);
|
|
685
847
|
const compliance = await readCompliance(repoRoot);
|
|
686
848
|
const rules = await rulesById();
|
|
@@ -700,16 +862,42 @@ export async function checkCompliance(repoPath) {
|
|
|
700
862
|
const recordedPlatforms = compliance.surface?.platforms || [];
|
|
701
863
|
const platformsToValidate = Array.from(new Set([...detectedPlatforms, ...recordedPlatforms]));
|
|
702
864
|
const platforms = platformsToValidate.length > 0 ? platformsToValidate : ["unknown"];
|
|
865
|
+
if (compliance.rules.length === 0) {
|
|
866
|
+
const expectedRules = await applicableRules(compliance.outcome, platforms);
|
|
867
|
+
if (expectedRules.length > 0) {
|
|
868
|
+
return {
|
|
869
|
+
status: "contract-drift",
|
|
870
|
+
exitCode: 4,
|
|
871
|
+
outcome: compliance.outcome,
|
|
872
|
+
surfacePath: compliance.surface?.path,
|
|
873
|
+
summary: { "contract-drift": 1 },
|
|
874
|
+
rules: [
|
|
875
|
+
{
|
|
876
|
+
ruleId: "contract.ruleset",
|
|
877
|
+
title: "Compliance ruleset is empty but a social.plus integration was detected",
|
|
878
|
+
severity: "error",
|
|
879
|
+
status: "stale",
|
|
880
|
+
reason: `sp-vise/compliance.json baked 0 compliance rules, but a social.plus integration was detected for platform(s) ${platforms.join(", ")} (${expectedRules.length} rule(s) apply). The sidecar was initialized before the platform was detectable (e.g. a CDN / no-package.json integration whose SDK usage is only in source) or is stale — a green here would certify unverified SDK code. Re-run \`vise init\` to bake the ruleset, then \`vise check\`.`,
|
|
881
|
+
},
|
|
882
|
+
],
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const { canonicalPlatform } = await import("./sdkFacts.js");
|
|
703
887
|
const allFindings = await Promise.all(platforms.map((p) => validateSetup(inspection.effectiveRoot, p)));
|
|
704
888
|
const findings = allFindings.flat();
|
|
705
889
|
const findingsById = new Map();
|
|
890
|
+
const findingsByIdAll = new Map();
|
|
706
891
|
for (const finding of findings) {
|
|
707
892
|
findingsById.set(finding.ruleId, finding);
|
|
893
|
+
appendFinding(findingsByIdAll, finding.ruleId, finding);
|
|
708
894
|
if (finding.sensorId) {
|
|
709
895
|
findingsById.set(finding.sensorId, finding);
|
|
896
|
+
appendFinding(findingsByIdAll, finding.sensorId, finding);
|
|
710
897
|
}
|
|
711
898
|
}
|
|
712
899
|
const attestations = await readAttestations(repoRoot);
|
|
900
|
+
const attestationHygiene = await readAttestationHygiene(repoRoot, compliance);
|
|
713
901
|
const results = [];
|
|
714
902
|
for (const ref of compliance.rules) {
|
|
715
903
|
const rule = rules.get(ref.rule_id);
|
|
@@ -738,14 +926,18 @@ export async function checkCompliance(repoPath) {
|
|
|
738
926
|
}
|
|
739
927
|
const hasDeterministicChecks = (rule.enforcement.deterministic ?? []).length > 0;
|
|
740
928
|
const isInferential = !hasDeterministicChecks && !!rule.enforcement.inferential;
|
|
741
|
-
const
|
|
742
|
-
|
|
929
|
+
const deterministic = hasDeterministicChecks
|
|
930
|
+
? await assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot)
|
|
931
|
+
: undefined;
|
|
932
|
+
const finding = deterministic?.finding;
|
|
933
|
+
if (hasDeterministicChecks && deterministic?.passed) {
|
|
743
934
|
results.push({
|
|
744
935
|
...checkRuleIdentity(rule.id),
|
|
745
936
|
title: rule.title,
|
|
746
937
|
severity: rule.severity,
|
|
747
938
|
status: "deterministic-pass",
|
|
748
|
-
|
|
939
|
+
pass_basis: deterministic.pass_basis,
|
|
940
|
+
evidence: deterministic.evidence,
|
|
749
941
|
});
|
|
750
942
|
continue;
|
|
751
943
|
}
|
|
@@ -761,10 +953,10 @@ export async function checkCompliance(repoPath) {
|
|
|
761
953
|
reason: rule.advisory
|
|
762
954
|
? "Advisory: informational only — does not affect compliance status."
|
|
763
955
|
: rule.enforcement.attestation.allowed
|
|
764
|
-
?
|
|
765
|
-
: "Current deterministic check failed; this rule does not allow attestation.",
|
|
956
|
+
? `Current deterministic check failed; previously synced deterministic-pass evidence is stale.${deterministic?.reason ? ` ${deterministic.reason}` : ""}`
|
|
957
|
+
: (deterministic?.reason ?? "Current deterministic check failed; this rule does not allow attestation."),
|
|
766
958
|
finding,
|
|
767
|
-
recommendation: finding?.recommendation,
|
|
959
|
+
recommendation: finding?.recommendation ?? deterministic?.recommendation,
|
|
768
960
|
rationale: rule.rationale,
|
|
769
961
|
current_rule: ruleSummary(rule),
|
|
770
962
|
...(failStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
@@ -795,6 +987,29 @@ export async function checkCompliance(repoPath) {
|
|
|
795
987
|
});
|
|
796
988
|
continue;
|
|
797
989
|
}
|
|
990
|
+
if (attestation.file_scoped === true && isFileScopableRule(rule)) {
|
|
991
|
+
const coveredFiles = new Set((attestation.source_fingerprints ?? []).map((fp) => fp.path));
|
|
992
|
+
const uncovered = await ruleUncoveredViolators(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll, coveredFiles);
|
|
993
|
+
if (uncovered.length > 0) {
|
|
994
|
+
const scopeStatus = rule.advisory ? "advisory" : rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail";
|
|
995
|
+
const uncoveredPaths = uniqueStrings(uncovered.map((f) => f.file).filter((f) => !!f));
|
|
996
|
+
results.push({
|
|
997
|
+
...checkRuleIdentity(rule.id),
|
|
998
|
+
title: rule.title,
|
|
999
|
+
severity: rule.severity,
|
|
1000
|
+
status: scopeStatus,
|
|
1001
|
+
reason: rule.advisory
|
|
1002
|
+
? "Advisory: informational only — does not affect compliance status."
|
|
1003
|
+
: `Attestation covers ${[...coveredFiles].join(", ")}; the rule also fires in ${uncoveredPaths.join(", ")}, which this attestation does not cover. Review the new violation(s) and re-attest (or fix them).`,
|
|
1004
|
+
finding: uncovered[0],
|
|
1005
|
+
recommendation: uncovered[0].recommendation,
|
|
1006
|
+
rationale: rule.rationale,
|
|
1007
|
+
current_rule: ruleSummary(rule),
|
|
1008
|
+
...(scopeStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
1009
|
+
});
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
798
1013
|
const deprecated = grandfathered && (rule.deprecated_versions ?? []).includes(attestation.rule_version);
|
|
799
1014
|
results.push({
|
|
800
1015
|
...checkRuleIdentity(rule.id),
|
|
@@ -824,6 +1039,9 @@ export async function checkCompliance(repoPath) {
|
|
|
824
1039
|
else if (isInferential) {
|
|
825
1040
|
fallbackReason = "Inferential check required. Please provide a host-agent attestation.";
|
|
826
1041
|
}
|
|
1042
|
+
else if (deterministic?.reason) {
|
|
1043
|
+
fallbackReason = deterministic.reason;
|
|
1044
|
+
}
|
|
827
1045
|
else if (rule.enforcement.attestation.allowed) {
|
|
828
1046
|
fallbackReason = "Deterministic check failed and no valid attestation exists.";
|
|
829
1047
|
}
|
|
@@ -834,7 +1052,7 @@ export async function checkCompliance(repoPath) {
|
|
|
834
1052
|
status: baseStatus,
|
|
835
1053
|
reason: fallbackReason,
|
|
836
1054
|
finding,
|
|
837
|
-
recommendation: finding?.recommendation,
|
|
1055
|
+
recommendation: finding?.recommendation ?? deterministic?.recommendation,
|
|
838
1056
|
current_rule: ruleSummary(rule),
|
|
839
1057
|
...(isInferential && { inferential_prompt: rule.enforcement.inferential?.prompt }),
|
|
840
1058
|
...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
@@ -858,49 +1076,99 @@ export async function checkCompliance(repoPath) {
|
|
|
858
1076
|
}
|
|
859
1077
|
}
|
|
860
1078
|
const summary = summarize(results);
|
|
861
|
-
const
|
|
862
|
-
const
|
|
863
|
-
const
|
|
1079
|
+
const deterministicPasses = results.filter((result) => result.status === "deterministic-pass");
|
|
1080
|
+
const runtimeSmokePasses = deterministicPasses.filter((result) => result.pass_basis === "runtime-smoke").length;
|
|
1081
|
+
const undetectedPlatformPasses = deterministicPasses.filter((result) => result.pass_basis === "undetected-platform").length;
|
|
1082
|
+
const absencePasses = deterministicPasses.length - runtimeSmokePasses - undetectedPlatformPasses;
|
|
1083
|
+
const evidenceBasis = deterministicPasses.length > 0
|
|
1084
|
+
? {
|
|
1085
|
+
absent: absencePasses,
|
|
1086
|
+
undetected_platform: undetectedPlatformPasses,
|
|
1087
|
+
...(runtimeSmokePasses > 0 ? { runtime_smoke: runtimeSmokePasses } : {}),
|
|
1088
|
+
}
|
|
1089
|
+
: undefined;
|
|
1090
|
+
const evidenceBasisNote = evidenceBasis
|
|
1091
|
+
? `${absencePasses + undetectedPlatformPasses} of the passing rules passed by ABSENCE (the sensor found no violation, which is not positive proof the feature was built).${undetectedPlatformPasses > 0
|
|
1092
|
+
? ` ${undetectedPlatformPasses} of those are vacuous — the rule's platform was not detected in this project, so no sensor ran.`
|
|
1093
|
+
: ""}${runtimeSmokePasses > 0 ? ` ${runtimeSmokePasses} deterministic-pass rule(s) cleared with runtime-smoke evidence from a mounted app.` : ""} Treat absence-based passes as "no problem found", not "built and validated".`
|
|
1094
|
+
: undefined;
|
|
864
1095
|
const sourceCompleteness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
|
|
865
1096
|
const blockProvided = sourceCompleteness && sourceCompleteness.missing.length > 0
|
|
866
1097
|
? await installedBlockProvidedCapabilities(repoRoot).catch(() => [])
|
|
867
1098
|
: [];
|
|
868
1099
|
const completeness = sourceCompleteness ? applyBlockProvidedCompleteness(sourceCompleteness, blockProvided) : undefined;
|
|
869
|
-
const hasCompletenessGap = (completeness?.missing.length ?? 0) > 0;
|
|
870
1100
|
const selectedOptionalCapabilities = (await assessProjectSelectedOptionalCapabilities(inspection.effectiveRoot, compliance.outcome, compliance.selected_optional_capabilities ?? []).catch(() => null)) ?? undefined;
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1101
|
+
const baselineFile = await readBaseline(repoRoot);
|
|
1102
|
+
let baselineReport;
|
|
1103
|
+
let gatedResults = results;
|
|
1104
|
+
let gatedMissing = completeness?.missing ?? [];
|
|
1105
|
+
let gatedSelectedFailed = selectedOptionalCapabilities?.failed ?? [];
|
|
1106
|
+
let gatedSelectedUnknown = selectedOptionalCapabilities?.unknown ?? [];
|
|
1107
|
+
if (options.newOnly) {
|
|
1108
|
+
if (!baselineFile) {
|
|
1109
|
+
baselineReport = { present: false, applied: false, reason: "No baseline recorded — `--new-only` gated on everything. Run `vise init --baseline` (or `vise baseline`) on the pre-existing codebase first." };
|
|
1110
|
+
}
|
|
1111
|
+
else if (baselineFile.ruleset_digest !== compliance.ruleset_digest) {
|
|
1112
|
+
baselineReport = { present: true, applied: false, stale: true, baselined_at: baselineFile.baselined_at, reason: "Baseline is stale: the ruleset changed since it was recorded, so it was NOT applied (gating on everything). Re-run `vise baseline` to refresh it against the current contract." };
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
const budget = new Map(Object.entries(baselineFile.findings));
|
|
1116
|
+
for (const result of results) {
|
|
1117
|
+
if (!BASELINE_NON_GREEN.has(result.status))
|
|
1118
|
+
continue;
|
|
1119
|
+
const key = baselineKeyFor(result);
|
|
1120
|
+
const remaining = budget.get(key) ?? 0;
|
|
1121
|
+
if (remaining > 0) {
|
|
1122
|
+
budget.set(key, remaining - 1);
|
|
1123
|
+
result.baselined = true;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const baselinedMissing = new Set(baselineFile.completeness_missing);
|
|
1127
|
+
const baselinedSelected = new Set(baselineFile.selected_optional);
|
|
1128
|
+
gatedResults = results.filter((result) => !result.baselined);
|
|
1129
|
+
gatedMissing = (completeness?.missing ?? []).filter((item) => !baselinedMissing.has(item.id));
|
|
1130
|
+
gatedSelectedFailed = (selectedOptionalCapabilities?.failed ?? []).filter((item) => !baselinedSelected.has(item.id));
|
|
1131
|
+
gatedSelectedUnknown = (selectedOptionalCapabilities?.unknown ?? []).filter((id) => !baselinedSelected.has(id));
|
|
1132
|
+
baselineReport = {
|
|
1133
|
+
present: true,
|
|
1134
|
+
applied: true,
|
|
1135
|
+
baselined_at: baselineFile.baselined_at,
|
|
1136
|
+
excluded: {
|
|
1137
|
+
rules: results.filter((result) => result.baselined).length,
|
|
1138
|
+
completeness: (completeness?.missing.length ?? 0) - gatedMissing.length,
|
|
1139
|
+
selected_optional: ((selectedOptionalCapabilities?.failed.length ?? 0) - gatedSelectedFailed.length) +
|
|
1140
|
+
((selectedOptionalCapabilities?.unknown.length ?? 0) - gatedSelectedUnknown.length),
|
|
1141
|
+
},
|
|
1142
|
+
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.",
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
else if (baselineFile) {
|
|
1147
|
+
baselineReport = { present: true, applied: false, baselined_at: baselineFile.baselined_at, hint: "A brownfield baseline exists. Run `vise check --new-only` to gate only on findings introduced since the baseline (pre-existing findings are excluded)." };
|
|
1148
|
+
}
|
|
1149
|
+
const hasCompletenessGap = gatedMissing.length > 0;
|
|
1150
|
+
const hasSelectedOptionalFailures = gatedSelectedFailed.length > 0 || gatedSelectedUnknown.length > 0;
|
|
1151
|
+
const { status, exitCode } = deriveGate({
|
|
1152
|
+
hasBlocked: gatedResults.some((result) => result.status === "blocked"),
|
|
1153
|
+
hasDeterministicFailure: gatedResults.some((result) => result.status === "deterministic-fail"),
|
|
1154
|
+
needsAttestation: gatedResults.some((result) => result.status === "attestation-needed" || result.status === "stale"),
|
|
1155
|
+
hasCompletenessGap,
|
|
1156
|
+
hasSelectedOptionalFailures,
|
|
1157
|
+
});
|
|
894
1158
|
const uxHarness = await readUxHarness(repoRoot);
|
|
895
1159
|
const uxAssessment = await assessUxHarness(inspection.effectiveRoot, uxHarness, compliance.outcome);
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1160
|
+
const openReviewItems = await experienceOpenReviewItems(repoRoot, findings);
|
|
1161
|
+
const experienceReport = {
|
|
1162
|
+
...buildExperienceReport({
|
|
1163
|
+
checkStatus: status,
|
|
1164
|
+
exitCode,
|
|
1165
|
+
summary,
|
|
1166
|
+
designContract: compliance.design_contract,
|
|
1167
|
+
uxAssessment,
|
|
1168
|
+
uxHarness,
|
|
1169
|
+
}),
|
|
1170
|
+
openReviewItems,
|
|
1171
|
+
};
|
|
904
1172
|
return {
|
|
905
1173
|
status,
|
|
906
1174
|
exitCode,
|
|
@@ -908,10 +1176,14 @@ export async function checkCompliance(repoPath) {
|
|
|
908
1176
|
surfacePath: compliance.surface?.path,
|
|
909
1177
|
summary,
|
|
910
1178
|
rules: results,
|
|
1179
|
+
...(evidenceBasis ? { evidence_basis: evidenceBasis } : {}),
|
|
1180
|
+
...(evidenceBasisNote ? { evidence_basis_note: evidenceBasisNote } : {}),
|
|
911
1181
|
...(completeness && (completeness.missing.length > 0 || completeness.optedOut.length > 0 || completeness.present.length > 0) ? { completeness } : {}),
|
|
912
1182
|
...(selectedOptionalCapabilities ? { selectedOptionalCapabilities } : {}),
|
|
1183
|
+
...(baselineReport ? { baseline: baselineReport } : {}),
|
|
913
1184
|
uxHarness: uxAssessment,
|
|
914
1185
|
experienceReport,
|
|
1186
|
+
...(attestationHygiene ? { attestation_hygiene: attestationHygiene } : {}),
|
|
915
1187
|
};
|
|
916
1188
|
}
|
|
917
1189
|
export async function generateExperienceReport(repoPath, options = {}) {
|
|
@@ -979,6 +1251,92 @@ function sensorReviewGaps(raw) {
|
|
|
979
1251
|
}
|
|
980
1252
|
return gaps.slice(0, 50);
|
|
981
1253
|
}
|
|
1254
|
+
async function experienceOpenReviewItems(repoRoot, findings) {
|
|
1255
|
+
const intake = await readIntakeReviewItems(repoRoot);
|
|
1256
|
+
const validateFindings = validateFindingReviewItems(findings);
|
|
1257
|
+
const needsReview = intake.unresolvedNonblockingIntake.length > 0 ||
|
|
1258
|
+
intake.acknowledgedBlockingIntake.length > 0 ||
|
|
1259
|
+
validateFindings.length > 0;
|
|
1260
|
+
return {
|
|
1261
|
+
status: needsReview ? "needs-review" : "clear",
|
|
1262
|
+
unresolvedNonblockingIntake: intake.unresolvedNonblockingIntake,
|
|
1263
|
+
acknowledgedBlockingIntake: intake.acknowledgedBlockingIntake,
|
|
1264
|
+
validateFindings,
|
|
1265
|
+
nextStep: needsReview
|
|
1266
|
+
? "Surface these items in the final handoff. Answer or explicitly scope unresolved intake, and treat validate findings as remaining review work even when vise check is green."
|
|
1267
|
+
: "No unresolved intake or validate findings were observed in the current report inputs.",
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
async function readIntakeReviewItems(repoRoot) {
|
|
1271
|
+
const sidecar = await readJsonIfExists(path.join(sidecarDir(repoRoot), "intake.json"));
|
|
1272
|
+
if (!sidecar) {
|
|
1273
|
+
return { unresolvedNonblockingIntake: [], acknowledgedBlockingIntake: [] };
|
|
1274
|
+
}
|
|
1275
|
+
const answers = sidecar.answers && typeof sidecar.answers === "object" && !Array.isArray(sidecar.answers)
|
|
1276
|
+
? sidecar.answers
|
|
1277
|
+
: {};
|
|
1278
|
+
const questions = Array.isArray(sidecar.questions) ? sidecar.questions : [];
|
|
1279
|
+
const unresolved = questions
|
|
1280
|
+
.map(intakeQuestionReviewItem)
|
|
1281
|
+
.filter((item) => Boolean(item))
|
|
1282
|
+
.filter((item) => !hasNonEmptyAnswer(answers, item.id));
|
|
1283
|
+
const acknowledgedBlocking = sidecar.acknowledged_unresolved_blocking === true
|
|
1284
|
+
? unresolved.filter((item) => item.blocking)
|
|
1285
|
+
: [];
|
|
1286
|
+
return {
|
|
1287
|
+
unresolvedNonblockingIntake: unresolved.filter((item) => !item.blocking).map(stripBlockingFlag),
|
|
1288
|
+
acknowledgedBlockingIntake: acknowledgedBlocking.map(stripBlockingFlag),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function intakeQuestionReviewItem(raw) {
|
|
1292
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1293
|
+
return undefined;
|
|
1294
|
+
}
|
|
1295
|
+
const question = raw;
|
|
1296
|
+
if (typeof question.id !== "string" || typeof question.question !== "string") {
|
|
1297
|
+
return undefined;
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
id: question.id,
|
|
1301
|
+
question: question.question,
|
|
1302
|
+
...(typeof question.why === "string" ? { why: question.why } : {}),
|
|
1303
|
+
...(Array.isArray(question.options) ? { options: question.options.filter((option) => typeof option === "string") } : {}),
|
|
1304
|
+
blocking: question.blocksImplementationWhenMissing === true,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
function stripBlockingFlag(item) {
|
|
1308
|
+
const { blocking: _blocking, ...rest } = item;
|
|
1309
|
+
return rest;
|
|
1310
|
+
}
|
|
1311
|
+
function hasNonEmptyAnswer(answers, id) {
|
|
1312
|
+
return Object.prototype.hasOwnProperty.call(answers, id) && String(answers[id] ?? "").trim() !== "";
|
|
1313
|
+
}
|
|
1314
|
+
function validateFindingReviewItems(findings) {
|
|
1315
|
+
const items = [];
|
|
1316
|
+
const seen = new Set();
|
|
1317
|
+
for (const finding of findings) {
|
|
1318
|
+
if (finding.severity !== "warning" && finding.severity !== "error") {
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
const key = `${finding.ruleId}|${finding.sensorId ?? ""}|${finding.file ?? ""}|${finding.message}`;
|
|
1322
|
+
if (seen.has(key)) {
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
seen.add(key);
|
|
1326
|
+
items.push({
|
|
1327
|
+
ruleId: finding.ruleId,
|
|
1328
|
+
...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
|
|
1329
|
+
severity: finding.severity,
|
|
1330
|
+
message: finding.message,
|
|
1331
|
+
...(finding.file ? { file: finding.file } : {}),
|
|
1332
|
+
...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
|
|
1333
|
+
});
|
|
1334
|
+
if (items.length >= 50) {
|
|
1335
|
+
break;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return items;
|
|
1339
|
+
}
|
|
982
1340
|
function fallbackExperienceReport(check) {
|
|
983
1341
|
const uxAssessment = check.uxHarness ?? {
|
|
984
1342
|
status: "absent",
|
|
@@ -1028,7 +1386,14 @@ export async function syncCompliance(repoPath) {
|
|
|
1028
1386
|
if (existing?.status === "attested") {
|
|
1029
1387
|
continue;
|
|
1030
1388
|
}
|
|
1031
|
-
const
|
|
1389
|
+
const passBasis = result.pass_basis ?? "absent";
|
|
1390
|
+
const confidence = passBasis === "runtime-smoke" ? "high" : passBasis === "undetected-platform" ? "low" : "medium";
|
|
1391
|
+
const rationale = passBasis === "runtime-smoke"
|
|
1392
|
+
? "Deterministic check passed: runtime mount-smoke evidence shows the declared collection surface(s) resolved in the running app."
|
|
1393
|
+
: passBasis === "undetected-platform"
|
|
1394
|
+
? "Deterministic check passed by absence, but this rule targets a platform not detected in this project, so no sensor exercised it — treat as not-yet-validated."
|
|
1395
|
+
: "Deterministic check passed: the sensor ran and found no violation. Absence of a finding is not positive proof the feature was built.";
|
|
1396
|
+
const attestation = buildAttestation(compliance, rule, "spf-deterministic", confidence, undefined, rationale, { ...(result.evidence ?? {}), pass_basis: passBasis });
|
|
1032
1397
|
await writeJson(filePath, attestation);
|
|
1033
1398
|
written.push(filePath);
|
|
1034
1399
|
}
|
|
@@ -1047,6 +1412,7 @@ export async function syncCompliance(repoPath) {
|
|
|
1047
1412
|
written,
|
|
1048
1413
|
removed,
|
|
1049
1414
|
last_synced_at: compliance.last_synced_at,
|
|
1415
|
+
...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
|
|
1050
1416
|
};
|
|
1051
1417
|
}
|
|
1052
1418
|
export async function attestRule(args) {
|
|
@@ -1090,9 +1456,16 @@ export async function attestRule(args) {
|
|
|
1090
1456
|
throw new Error(`Rule does not allow local human attestations: ${args.ruleId}`);
|
|
1091
1457
|
}
|
|
1092
1458
|
validateEvidence(rule, args.evidence);
|
|
1459
|
+
const { warning: sensorWarning, violatorFiles } = await liveSensorContradiction(repoRoot, compliance, rule);
|
|
1093
1460
|
await mkdir(attestationsDir(repoRoot), { recursive: true });
|
|
1094
|
-
const
|
|
1095
|
-
const
|
|
1461
|
+
const sourceRoot = sourceRootForCompliance(repoRoot, compliance);
|
|
1462
|
+
const evidenceFingerprints = await collectSourceFingerprints(repoRoot, sourceRoot, args.evidence);
|
|
1463
|
+
const fileScoped = isFileScopableRule(rule);
|
|
1464
|
+
const violatorFingerprints = fileScoped && violatorFiles.length > 0
|
|
1465
|
+
? await collectViolatorFingerprints(repoRoot, sourceRoot, violatorFiles, evidenceFingerprints)
|
|
1466
|
+
: [];
|
|
1467
|
+
const sourceFingerprints = [...evidenceFingerprints, ...violatorFingerprints];
|
|
1468
|
+
const attestation = buildAttestation(compliance, rule, args.signer, args.confidence, args.identity, args.rationale, args.evidence, sourceFingerprints, fileScoped);
|
|
1096
1469
|
const filePath = path.join(attestationsDir(repoRoot), attestationPathFor(contractRuleId));
|
|
1097
1470
|
await writeJson(filePath, attestation);
|
|
1098
1471
|
return {
|
|
@@ -1101,6 +1474,8 @@ export async function attestRule(args) {
|
|
|
1101
1474
|
destination: filePath,
|
|
1102
1475
|
signer_claim: attestation.signer_claim,
|
|
1103
1476
|
source_fingerprints: sourceFingerprints,
|
|
1477
|
+
...(fileScoped ? { file_scoped: true, scoped_files: [...new Set(sourceFingerprints.map((fp) => fp.path))] } : {}),
|
|
1478
|
+
...(sensorWarning ? { sensor_warning: sensorWarning } : {}),
|
|
1104
1479
|
};
|
|
1105
1480
|
}
|
|
1106
1481
|
export async function explainRule(ruleId) {
|
|
@@ -1124,6 +1499,9 @@ export async function explainRule(ruleId) {
|
|
|
1124
1499
|
title: candidate.title,
|
|
1125
1500
|
severity: candidate.severity,
|
|
1126
1501
|
rationale: candidate.rationale,
|
|
1502
|
+
...(candidate.feedforward ? { feedforward: candidate.feedforward } : {}),
|
|
1503
|
+
...(candidate.symptoms && candidate.symptoms.length > 0 ? { symptoms: candidate.symptoms } : {}),
|
|
1504
|
+
...(candidate.enforcement.inferential ? { inferential: candidate.enforcement.inferential } : {}),
|
|
1127
1505
|
applies_when: candidate.applies_when,
|
|
1128
1506
|
attestation: candidate.enforcement.attestation,
|
|
1129
1507
|
summary: ruleSummary(candidate),
|
|
@@ -1142,6 +1520,9 @@ export async function explainRule(ruleId) {
|
|
|
1142
1520
|
title: rule.title,
|
|
1143
1521
|
severity: rule.severity,
|
|
1144
1522
|
rationale: rule.rationale,
|
|
1523
|
+
...(rule.feedforward ? { feedforward: rule.feedforward } : {}),
|
|
1524
|
+
...(rule.symptoms && rule.symptoms.length > 0 ? { symptoms: rule.symptoms } : {}),
|
|
1525
|
+
...(rule.enforcement.inferential ? { inferential: rule.enforcement.inferential } : {}),
|
|
1145
1526
|
applies_when: rule.applies_when,
|
|
1146
1527
|
compatible_with: rule.compatible_with,
|
|
1147
1528
|
deterministic: rule.enforcement.deterministic ?? [],
|
|
@@ -1151,9 +1532,44 @@ export async function explainRule(ruleId) {
|
|
|
1151
1532
|
freshness: await ruleFreshness(rule),
|
|
1152
1533
|
};
|
|
1153
1534
|
}
|
|
1154
|
-
export async function
|
|
1535
|
+
export async function listRules() {
|
|
1536
|
+
const rules = await rulesById();
|
|
1537
|
+
const byPublic = new Map();
|
|
1538
|
+
for (const rule of rules.values()) {
|
|
1539
|
+
const publicId = publicProductRuleId(rule.id);
|
|
1540
|
+
let entry = byPublic.get(publicId);
|
|
1541
|
+
if (!entry) {
|
|
1542
|
+
entry = { id: publicId, contract_rule_ids: new Set(), titles: new Set(), severities: new Set(), outcomes: new Set(), platforms: new Set() };
|
|
1543
|
+
byPublic.set(publicId, entry);
|
|
1544
|
+
}
|
|
1545
|
+
entry.contract_rule_ids.add(rule.id);
|
|
1546
|
+
entry.titles.add(rule.title);
|
|
1547
|
+
entry.severities.add(rule.severity);
|
|
1548
|
+
for (const outcome of rule.applies_when.outcomes ?? [])
|
|
1549
|
+
entry.outcomes.add(outcome);
|
|
1550
|
+
for (const platform of rule.applies_when.platforms ?? [])
|
|
1551
|
+
entry.platforms.add(platform);
|
|
1552
|
+
}
|
|
1553
|
+
const entries = [...byPublic.values()]
|
|
1554
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
1555
|
+
.map((entry) => ({
|
|
1556
|
+
id: entry.id,
|
|
1557
|
+
contract_rule_ids: [...entry.contract_rule_ids].sort(),
|
|
1558
|
+
title: [...entry.titles][0],
|
|
1559
|
+
severity: [...entry.severities].sort()[0],
|
|
1560
|
+
outcomes: [...entry.outcomes].sort(),
|
|
1561
|
+
platforms: [...entry.platforms].sort(),
|
|
1562
|
+
}));
|
|
1563
|
+
return {
|
|
1564
|
+
kind: "rule-index",
|
|
1565
|
+
count: entries.length,
|
|
1566
|
+
hint: "Run `vise explain <id>` with any id below (a public product id or a contract_rule_id) for the full rule, evidence, and attestation policy.",
|
|
1567
|
+
rules: entries,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
export async function statusCompliance(repoPath, options = {}) {
|
|
1155
1571
|
const repoRoot = path.resolve(repoPath);
|
|
1156
|
-
const check = await checkCompliance(repoPath);
|
|
1572
|
+
const check = await checkCompliance(repoPath, options);
|
|
1157
1573
|
const engagement = await readEngagement(repoRoot);
|
|
1158
1574
|
const compliance = await readCompliance(repoRoot).catch(() => null);
|
|
1159
1575
|
return {
|
|
@@ -1162,6 +1578,11 @@ export async function statusCompliance(repoPath) {
|
|
|
1162
1578
|
outcome: check.outcome,
|
|
1163
1579
|
surfacePath: check.surfacePath,
|
|
1164
1580
|
summary: check.summary,
|
|
1581
|
+
...(check.evidence_basis ? { evidence_basis: check.evidence_basis } : {}),
|
|
1582
|
+
...(check.evidence_basis_note ? { evidence_basis_note: check.evidence_basis_note } : {}),
|
|
1583
|
+
...(check.completeness ? { completeness: check.completeness } : {}),
|
|
1584
|
+
...(check.selectedOptionalCapabilities ? { selectedOptionalCapabilities: check.selectedOptionalCapabilities } : {}),
|
|
1585
|
+
...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
|
|
1165
1586
|
...(compliance?.design_contract && { design_contract: compliance.design_contract }),
|
|
1166
1587
|
...(engagement && {
|
|
1167
1588
|
engagement: {
|
|
@@ -1331,6 +1752,187 @@ function contractDrift(compliance, rules) {
|
|
|
1331
1752
|
}
|
|
1332
1753
|
return results;
|
|
1333
1754
|
}
|
|
1755
|
+
async function assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot) {
|
|
1756
|
+
const checks = rule.enforcement.deterministic ?? [];
|
|
1757
|
+
const passedEvidence = [];
|
|
1758
|
+
let passBasis = "absent";
|
|
1759
|
+
for (const check of checks) {
|
|
1760
|
+
if (check.check === "validator-finding-absent") {
|
|
1761
|
+
const finding = findingsById.get(check.finding_rule_id);
|
|
1762
|
+
if (finding) {
|
|
1763
|
+
return {
|
|
1764
|
+
passed: false,
|
|
1765
|
+
finding,
|
|
1766
|
+
reason: "Current deterministic validator check failed.",
|
|
1767
|
+
recommendation: finding.recommendation,
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
passedEvidence.push({ source: "validate_setup", finding_absent: [check.finding_rule_id] });
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
if (check.check === "runtime-smoke-evidence-passed") {
|
|
1774
|
+
const smoke = await assessRuntimeSmokeEvidence(repoRoot, check);
|
|
1775
|
+
if (!smoke.passed) {
|
|
1776
|
+
return smoke;
|
|
1777
|
+
}
|
|
1778
|
+
if (smoke.evidence) {
|
|
1779
|
+
passedEvidence.push(smoke.evidence);
|
|
1780
|
+
}
|
|
1781
|
+
passBasis = "runtime-smoke";
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
const unknown = check;
|
|
1785
|
+
return {
|
|
1786
|
+
passed: false,
|
|
1787
|
+
reason: `Unsupported deterministic check kind: ${String(unknown.check ?? "(missing)")}.`,
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
if (passBasis !== "runtime-smoke") {
|
|
1791
|
+
const identity = checkRuleIdentity(rule.id);
|
|
1792
|
+
const rulePlatform = identity.validator?.platform;
|
|
1793
|
+
passBasis =
|
|
1794
|
+
rulePlatform && !platforms.some((p) => canonicalPlatform(p) === canonicalPlatform(rulePlatform))
|
|
1795
|
+
? "undetected-platform"
|
|
1796
|
+
: "absent";
|
|
1797
|
+
}
|
|
1798
|
+
const validatorAbsent = deterministicFindingIds(rule);
|
|
1799
|
+
const runtimeSmoke = passedEvidence.find((item) => item.source === "runtime_smoke");
|
|
1800
|
+
let evidence;
|
|
1801
|
+
if (runtimeSmoke && validatorAbsent.length > 0) {
|
|
1802
|
+
evidence = {
|
|
1803
|
+
source: "deterministic_checks",
|
|
1804
|
+
validate_setup: { finding_absent: validatorAbsent },
|
|
1805
|
+
runtime_smoke: runtimeSmoke,
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
else if (runtimeSmoke) {
|
|
1809
|
+
evidence = runtimeSmoke;
|
|
1810
|
+
}
|
|
1811
|
+
else {
|
|
1812
|
+
evidence = { source: "validate_setup", finding_absent: validatorAbsent };
|
|
1813
|
+
}
|
|
1814
|
+
return {
|
|
1815
|
+
passed: true,
|
|
1816
|
+
pass_basis: passBasis,
|
|
1817
|
+
evidence,
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
async function assessRuntimeSmokeEvidence(repoRoot, check) {
|
|
1821
|
+
const relativePath = check.path ?? "sp-vise/evidence/runtime-smoke.json";
|
|
1822
|
+
const evidencePath = path.resolve(repoRoot, relativePath);
|
|
1823
|
+
const displayPath = path.relative(repoRoot, evidencePath).split(path.sep).join(path.posix.sep);
|
|
1824
|
+
const recommendation = runtimeSmokeCaptureRecommendationText();
|
|
1825
|
+
if (!pathInside(repoRoot, evidencePath)) {
|
|
1826
|
+
return {
|
|
1827
|
+
passed: false,
|
|
1828
|
+
reason: `Runtime smoke evidence path must stay inside the repository: ${relativePath}`,
|
|
1829
|
+
recommendation,
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
let parsed;
|
|
1833
|
+
try {
|
|
1834
|
+
parsed = JSON.parse(await readFile(evidencePath, "utf8"));
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
return {
|
|
1838
|
+
passed: false,
|
|
1839
|
+
reason: `Missing or unreadable runtime smoke evidence at ${displayPath}.`,
|
|
1840
|
+
recommendation,
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
const results = Array.isArray(parsed.results) ? parsed.results : [];
|
|
1844
|
+
const surfaceResults = results.filter(isRuntimeSmokeSurfaceResult);
|
|
1845
|
+
const failing = surfaceResults.filter((result) => result.verdict !== "pass");
|
|
1846
|
+
const evidence = {
|
|
1847
|
+
source: "runtime_smoke",
|
|
1848
|
+
file: displayPath,
|
|
1849
|
+
passed: parsed.passed === true,
|
|
1850
|
+
...(typeof parsed.platform === "string" ? { platform: parsed.platform } : {}),
|
|
1851
|
+
results: surfaceResults,
|
|
1852
|
+
};
|
|
1853
|
+
if (parsed.passed !== true) {
|
|
1854
|
+
const failedSurfaces = failing.map((result) => `${result.surface}:${result.state}`).join(", ");
|
|
1855
|
+
return {
|
|
1856
|
+
passed: false,
|
|
1857
|
+
evidence,
|
|
1858
|
+
reason: failedSurfaces
|
|
1859
|
+
? `Runtime smoke evidence at ${displayPath} did not pass (${failedSurfaces}).`
|
|
1860
|
+
: `Runtime smoke evidence at ${displayPath} did not pass.`,
|
|
1861
|
+
recommendation,
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
if (surfaceResults.length === 0) {
|
|
1865
|
+
return {
|
|
1866
|
+
passed: false,
|
|
1867
|
+
evidence,
|
|
1868
|
+
reason: `Runtime smoke evidence at ${displayPath} has passed=true but no surface results; rerun \`vise smoke\` so the gate can verify a mounted collection surface.`,
|
|
1869
|
+
recommendation,
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
if (failing.length > 0) {
|
|
1873
|
+
return {
|
|
1874
|
+
passed: false,
|
|
1875
|
+
evidence,
|
|
1876
|
+
reason: `Runtime smoke evidence at ${displayPath} has passed=true but contains failing surface results (${failing.map((result) => result.surface).join(", ")}).`,
|
|
1877
|
+
recommendation,
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
const smokeConfig = await readJsonIfExists(path.join(sidecarDir(repoRoot), "smoke.json"));
|
|
1881
|
+
const expectedSurfaceIds = Array.isArray(smokeConfig?.surfaces)
|
|
1882
|
+
? smokeConfig.surfaces.map((surface) => surface.id).filter((id) => typeof id === "string" && id.length > 0)
|
|
1883
|
+
: [];
|
|
1884
|
+
if (expectedSurfaceIds.length > 0) {
|
|
1885
|
+
const passedSurfaceIds = new Set(surfaceResults.filter((result) => result.verdict === "pass").map((result) => result.surface));
|
|
1886
|
+
const missing = expectedSurfaceIds.filter((id) => !passedSurfaceIds.has(id));
|
|
1887
|
+
if (missing.length > 0) {
|
|
1888
|
+
return {
|
|
1889
|
+
passed: false,
|
|
1890
|
+
evidence,
|
|
1891
|
+
reason: `Runtime smoke evidence at ${displayPath} does not include passing results for declared collection surface(s): ${missing.join(", ")}.`,
|
|
1892
|
+
recommendation,
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
const expectedSurfacesById = new Map(smokeConfig?.surfaces
|
|
1896
|
+
?.filter((surface) => typeof surface.id === "string" && surface.id.length > 0)
|
|
1897
|
+
.map((surface) => [surface.id, surface]) ?? []);
|
|
1898
|
+
const underpopulated = surfaceResults.filter((result) => {
|
|
1899
|
+
const expected = expectedSurfacesById.get(result.surface);
|
|
1900
|
+
return expected?.expect === "populated" && result.verdict === "pass" && !runtimeSmokeStateHasPositiveCount(result.state);
|
|
1901
|
+
});
|
|
1902
|
+
if (underpopulated.length > 0) {
|
|
1903
|
+
return {
|
|
1904
|
+
passed: false,
|
|
1905
|
+
evidence,
|
|
1906
|
+
reason: `Runtime smoke evidence at ${displayPath} has passing populated surface result(s) without count > 0: ${underpopulated.map((result) => `${result.surface}:${result.state}`).join(", ")}.`,
|
|
1907
|
+
recommendation,
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return {
|
|
1912
|
+
passed: true,
|
|
1913
|
+
pass_basis: "runtime-smoke",
|
|
1914
|
+
evidence,
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
function runtimeSmokeCaptureRecommendationText() {
|
|
1918
|
+
return [
|
|
1919
|
+
"Declare collection surfaces in `sp-vise/smoke.json` and emit `VISE_SMOKE surface=<id> state=loading|empty|loaded count=N|error:<message>` from the mounted app's real SDK query lifecycle; `expect: \"populated\"` requires loaded count=N with N > 0.",
|
|
1920
|
+
"Cold-launch into the social route, wait for the visible collection to resolve, capture only the runtime log, run `vise smoke . --log <capture>`, then rerun `vise check`.",
|
|
1921
|
+
"Android: `adb logcat -c` before launch, then `adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log`.",
|
|
1922
|
+
"iOS: after `simctl launch`, run `xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log`.",
|
|
1923
|
+
"Web/React Native/Flutter: capture console/debugPrint VISE_SMOKE lines from the real mounted app, not a standalone SDK probe.",
|
|
1924
|
+
].join(" ");
|
|
1925
|
+
}
|
|
1926
|
+
function isRuntimeSmokeSurfaceResult(value) {
|
|
1927
|
+
if (!value || typeof value !== "object")
|
|
1928
|
+
return false;
|
|
1929
|
+
const item = value;
|
|
1930
|
+
return typeof item.surface === "string" && typeof item.state === "string" && typeof item.verdict === "string";
|
|
1931
|
+
}
|
|
1932
|
+
function runtimeSmokeStateHasPositiveCount(state) {
|
|
1933
|
+
const match = state.match(/^loaded\b[\s\S]*?\bcount\s*=\s*(\d+)/i);
|
|
1934
|
+
return Boolean(match && Number(match[1]) > 0);
|
|
1935
|
+
}
|
|
1334
1936
|
function deterministicFinding(rule, findingsById) {
|
|
1335
1937
|
for (const id of deterministicFindingIds(rule)) {
|
|
1336
1938
|
const finding = findingsById.get(id);
|
|
@@ -1340,11 +1942,159 @@ function deterministicFinding(rule, findingsById) {
|
|
|
1340
1942
|
}
|
|
1341
1943
|
return undefined;
|
|
1342
1944
|
}
|
|
1945
|
+
async function liveSensorContradiction(repoRoot, compliance, rule) {
|
|
1946
|
+
if (deterministicFindingIds(rule).length === 0) {
|
|
1947
|
+
return { violatorFiles: [] };
|
|
1948
|
+
}
|
|
1949
|
+
const inspection = await inspectProject(repoRoot, compliance.surface?.path === "." ? undefined : compliance.surface?.path);
|
|
1950
|
+
const platforms = Array.from(new Set([...inspection.platforms, ...(compliance.surface?.platforms ?? [])]));
|
|
1951
|
+
if (platforms.length === 0) {
|
|
1952
|
+
return { violatorFiles: [] };
|
|
1953
|
+
}
|
|
1954
|
+
const allFindings = await Promise.all(platforms.map((platform) => validateSetup(inspection.effectiveRoot, platform)));
|
|
1955
|
+
const findingsById = new Map();
|
|
1956
|
+
const findingsByIdAll = new Map();
|
|
1957
|
+
for (const finding of allFindings.flat()) {
|
|
1958
|
+
findingsById.set(finding.ruleId, finding);
|
|
1959
|
+
appendFinding(findingsByIdAll, finding.ruleId, finding);
|
|
1960
|
+
if (finding.sensorId) {
|
|
1961
|
+
findingsById.set(finding.sensorId, finding);
|
|
1962
|
+
appendFinding(findingsByIdAll, finding.sensorId, finding);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
const finding = deterministicFinding(rule, findingsById);
|
|
1966
|
+
if (!finding) {
|
|
1967
|
+
return { violatorFiles: [] };
|
|
1968
|
+
}
|
|
1969
|
+
const violatorFiles = await ruleViolatorFiles(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll);
|
|
1970
|
+
return {
|
|
1971
|
+
warning: {
|
|
1972
|
+
message: "The live deterministic sensor STILL reports a violation for this rule. This attestation records your override; verify the source actually satisfies the rule before relying on it.",
|
|
1973
|
+
sensor_finding: {
|
|
1974
|
+
ruleId: finding.ruleId,
|
|
1975
|
+
...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
|
|
1976
|
+
...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
|
|
1977
|
+
},
|
|
1978
|
+
},
|
|
1979
|
+
violatorFiles,
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1343
1982
|
function deterministicFindingIds(rule) {
|
|
1344
1983
|
return (rule.enforcement.deterministic ?? [])
|
|
1345
1984
|
.filter((check) => check.check === "validator-finding-absent")
|
|
1346
1985
|
.map((check) => check.finding_rule_id);
|
|
1347
1986
|
}
|
|
1987
|
+
function appendFinding(map, key, finding) {
|
|
1988
|
+
const existing = map.get(key);
|
|
1989
|
+
if (existing) {
|
|
1990
|
+
existing.push(finding);
|
|
1991
|
+
}
|
|
1992
|
+
else {
|
|
1993
|
+
map.set(key, [finding]);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
const LIVE_SENSOR_VIOLATOR_FIELD = "live_sensor_violator";
|
|
1997
|
+
export const FILE_SCOPABLE_SENSOR_SUFFIXES = new Set([
|
|
1998
|
+
"live-collection.api-mismatch",
|
|
1999
|
+
"story.live-collection",
|
|
2000
|
+
"search.live-collection",
|
|
2001
|
+
"notification-tray.live-collection",
|
|
2002
|
+
"event.live-collection",
|
|
2003
|
+
"blocked-users.live-collection",
|
|
2004
|
+
"invitation.live-collection",
|
|
2005
|
+
"membership.use-live-collection",
|
|
2006
|
+
"posts.status-filter-applied",
|
|
2007
|
+
"posts.activity-tag-filter",
|
|
2008
|
+
"posts.reaction-stale-post-ref",
|
|
2009
|
+
"poll.vote-status-guard",
|
|
2010
|
+
"pagination.cursor-opaque",
|
|
2011
|
+
"posts.parent-child-rendered",
|
|
2012
|
+
"comment.reference-type-enum",
|
|
2013
|
+
"channel.type-matches-shape",
|
|
2014
|
+
"reactions.configured-name-used",
|
|
2015
|
+
"image-post.child-resolution-awaited",
|
|
2016
|
+
"file-upload.via-amity-file-client",
|
|
2017
|
+
"user.ban-state-respected",
|
|
2018
|
+
"flag-count.not-leaked-to-non-mods",
|
|
2019
|
+
"moderation.role-gated-action",
|
|
2020
|
+
"custom-post-type.dataType-declared",
|
|
2021
|
+
"feed.target-type-explicit",
|
|
2022
|
+
"feed.post-datatype-handled",
|
|
2023
|
+
"feed.ui-states-present",
|
|
2024
|
+
"comments.target-resolved",
|
|
2025
|
+
"chat.channel-type-dm",
|
|
2026
|
+
"chat.deprecated-marker-sync",
|
|
2027
|
+
"chat.deprecated-subchannel",
|
|
2028
|
+
"livestream.deprecated-stream",
|
|
2029
|
+
"follow.status-subscription",
|
|
2030
|
+
"session-handler.retained",
|
|
2031
|
+
"client.no-ssr-init",
|
|
2032
|
+
"community.avatar-from-sdk",
|
|
2033
|
+
"feed.poll-answer-data-shape",
|
|
2034
|
+
"comments.query-has-limit",
|
|
2035
|
+
"comments.thread-ui-states-present",
|
|
2036
|
+
"chat.sort-explicit",
|
|
2037
|
+
"profile.social-counts-from-sdk",
|
|
2038
|
+
"community.display-name-from-sdk",
|
|
2039
|
+
"feed.room-post-fetched",
|
|
2040
|
+
"logging.no-secret-in-log",
|
|
2041
|
+
"logging.no-pii-in-log",
|
|
2042
|
+
"auth.no-literal-user-id",
|
|
2043
|
+
"feed.target.literal",
|
|
2044
|
+
]);
|
|
2045
|
+
function isFileScopableRule(rule) {
|
|
2046
|
+
return deterministicFindingIds(rule).some((id) => [...FILE_SCOPABLE_SENSOR_SUFFIXES].some((suffix) => id === suffix || id.endsWith(`.${suffix}`)));
|
|
2047
|
+
}
|
|
2048
|
+
async function ruleViolatorFiles(repoRoot, sourceRoot, rule, findingsByIdAll) {
|
|
2049
|
+
const files = new Set();
|
|
2050
|
+
for (const id of deterministicFindingIds(rule)) {
|
|
2051
|
+
for (const finding of findingsByIdAll.get(id) ?? []) {
|
|
2052
|
+
if (!finding.file)
|
|
2053
|
+
continue;
|
|
2054
|
+
const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, finding.file);
|
|
2055
|
+
files.add(resolved ? resolved.relativePath : finding.file);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
return [...files];
|
|
2059
|
+
}
|
|
2060
|
+
async function ruleUncoveredViolators(repoRoot, sourceRoot, rule, findingsByIdAll, coveredFiles) {
|
|
2061
|
+
const uncovered = [];
|
|
2062
|
+
const seen = new Set();
|
|
2063
|
+
for (const id of deterministicFindingIds(rule)) {
|
|
2064
|
+
for (const finding of findingsByIdAll.get(id) ?? []) {
|
|
2065
|
+
if (!finding.file)
|
|
2066
|
+
continue;
|
|
2067
|
+
const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, finding.file);
|
|
2068
|
+
const normalized = resolved ? resolved.relativePath : finding.file;
|
|
2069
|
+
if (coveredFiles.has(normalized) || seen.has(normalized))
|
|
2070
|
+
continue;
|
|
2071
|
+
seen.add(normalized);
|
|
2072
|
+
uncovered.push({ ...finding, file: normalized });
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return uncovered;
|
|
2076
|
+
}
|
|
2077
|
+
async function collectViolatorFingerprints(repoRoot, sourceRoot, violatorFiles, evidenceFingerprints) {
|
|
2078
|
+
const capturedAt = new Date().toISOString();
|
|
2079
|
+
const alreadyFingerprinted = new Set(evidenceFingerprints.map((fp) => fp.path));
|
|
2080
|
+
const fingerprints = [];
|
|
2081
|
+
for (const file of violatorFiles) {
|
|
2082
|
+
const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, file);
|
|
2083
|
+
if (!resolved || alreadyFingerprinted.has(resolved.relativePath)) {
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
alreadyFingerprinted.add(resolved.relativePath);
|
|
2087
|
+
const fileStat = await stat(resolved.absolutePath);
|
|
2088
|
+
fingerprints.push({
|
|
2089
|
+
evidence_field: LIVE_SENSOR_VIOLATOR_FIELD,
|
|
2090
|
+
path: resolved.relativePath,
|
|
2091
|
+
sha256: await digestFile(resolved.absolutePath),
|
|
2092
|
+
size_bytes: fileStat.size,
|
|
2093
|
+
captured_at: capturedAt,
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
return fingerprints;
|
|
2097
|
+
}
|
|
1348
2098
|
function uniqueStrings(values) {
|
|
1349
2099
|
const seen = new Set();
|
|
1350
2100
|
const result = [];
|
|
@@ -1357,7 +2107,7 @@ function uniqueStrings(values) {
|
|
|
1357
2107
|
}
|
|
1358
2108
|
return result;
|
|
1359
2109
|
}
|
|
1360
|
-
function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = []) {
|
|
2110
|
+
function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = [], fileScoped = false) {
|
|
1361
2111
|
const ref = compliance.rules.find((item) => item.rule_id === rule.id);
|
|
1362
2112
|
if (!ref) {
|
|
1363
2113
|
throw new Error(`Rule is not in compliance contract: ${rule.id}`);
|
|
@@ -1379,6 +2129,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
|
|
|
1379
2129
|
signature: null,
|
|
1380
2130
|
evidence,
|
|
1381
2131
|
source_fingerprints: sourceFingerprints,
|
|
2132
|
+
...(fileScoped ? { file_scoped: true } : {}),
|
|
1382
2133
|
rationale_for_attestation: rationale,
|
|
1383
2134
|
attested_at: new Date().toISOString(),
|
|
1384
2135
|
};
|
|
@@ -1527,13 +2278,19 @@ export function ruleSummary(rule) {
|
|
|
1527
2278
|
pattern: "pattern" in blocker ? blocker.pattern : undefined,
|
|
1528
2279
|
reason: blocker.reason,
|
|
1529
2280
|
})),
|
|
1530
|
-
deterministic_check_ids: (rule.enforcement.deterministic ?? []).map(
|
|
2281
|
+
deterministic_check_ids: (rule.enforcement.deterministic ?? []).map(deterministicCheckId),
|
|
1531
2282
|
attestation_allowed: rule.enforcement.attestation.allowed,
|
|
1532
2283
|
host_agent_min_confidence: rule.enforcement.attestation.host_agent_min_confidence,
|
|
1533
2284
|
human_allowed: rule.enforcement.attestation.human_allowed !== false,
|
|
1534
2285
|
evidence_field_names: (rule.enforcement.attestation.evidence_required ?? []).map((field) => field.field),
|
|
1535
2286
|
};
|
|
1536
2287
|
}
|
|
2288
|
+
function deterministicCheckId(check) {
|
|
2289
|
+
if (check.check === "validator-finding-absent") {
|
|
2290
|
+
return check.finding_rule_id;
|
|
2291
|
+
}
|
|
2292
|
+
return `${check.check}:${check.path ?? "sp-vise/evidence/runtime-smoke.json"}`;
|
|
2293
|
+
}
|
|
1537
2294
|
export async function runBlockers(rule, root) {
|
|
1538
2295
|
const fired = [];
|
|
1539
2296
|
for (const blocker of rule.enforcement.blockers ?? []) {
|
|
@@ -1616,16 +2373,78 @@ async function readAttestations(repoRoot) {
|
|
|
1616
2373
|
if (!entry.endsWith(".json")) {
|
|
1617
2374
|
continue;
|
|
1618
2375
|
}
|
|
1619
|
-
const
|
|
1620
|
-
if (
|
|
1621
|
-
|
|
1622
|
-
if (digestJson(withoutHash) === payload_hash) {
|
|
1623
|
-
result.set(attestation.rule_id, attestation);
|
|
1624
|
-
}
|
|
2376
|
+
const candidate = await readAttestationCandidate(path.join(dir, entry));
|
|
2377
|
+
if (candidate.status === "valid") {
|
|
2378
|
+
result.set(candidate.attestation.rule_id, candidate.attestation);
|
|
1625
2379
|
}
|
|
1626
2380
|
}
|
|
1627
2381
|
return result;
|
|
1628
2382
|
}
|
|
2383
|
+
async function readAttestationHygiene(repoRoot, compliance) {
|
|
2384
|
+
const dir = attestationsDir(repoRoot);
|
|
2385
|
+
const activeRuleIds = new Set(compliance.rules.map((rule) => rule.rule_id));
|
|
2386
|
+
const ignored = [];
|
|
2387
|
+
const invalid = [];
|
|
2388
|
+
let entries = [];
|
|
2389
|
+
try {
|
|
2390
|
+
entries = await readdir(dir);
|
|
2391
|
+
}
|
|
2392
|
+
catch {
|
|
2393
|
+
return undefined;
|
|
2394
|
+
}
|
|
2395
|
+
for (const entry of entries) {
|
|
2396
|
+
if (!entry.endsWith(".json")) {
|
|
2397
|
+
continue;
|
|
2398
|
+
}
|
|
2399
|
+
const rel = path.posix.join(complianceDirName, attestationsDirName, entry);
|
|
2400
|
+
const candidate = await readAttestationCandidate(path.join(dir, entry));
|
|
2401
|
+
if (candidate.status === "invalid") {
|
|
2402
|
+
invalid.push({ file: rel, ...(candidate.rule_id ? { rule_id: candidate.rule_id } : {}), reason: candidate.reason });
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
if (!activeRuleIds.has(candidate.attestation.rule_id)) {
|
|
2406
|
+
ignored.push({
|
|
2407
|
+
file: rel,
|
|
2408
|
+
rule_id: candidate.attestation.rule_id,
|
|
2409
|
+
reason: "Attestation rule_id is not in the current compliance contract. It may be stale, off-platform, or from a previous sidecar scope; it is ignored by vise check.",
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
if (ignored.length === 0 && invalid.length === 0) {
|
|
2414
|
+
return undefined;
|
|
2415
|
+
}
|
|
2416
|
+
return {
|
|
2417
|
+
status: "needs-review",
|
|
2418
|
+
ignored,
|
|
2419
|
+
invalid,
|
|
2420
|
+
nextStep: "Review ignored/invalid attestation files before using the evidence trail for handoff. Remove stale files only when you are sure they are unrelated, or re-run vise init/check/sync for the intended scope.",
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
async function readAttestationCandidate(filePath) {
|
|
2424
|
+
let parsed;
|
|
2425
|
+
try {
|
|
2426
|
+
parsed = JSON.parse(await readFile(filePath, "utf8"));
|
|
2427
|
+
}
|
|
2428
|
+
catch {
|
|
2429
|
+
return { status: "invalid", reason: "Attestation file is not valid JSON." };
|
|
2430
|
+
}
|
|
2431
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2432
|
+
return { status: "invalid", reason: "Attestation file is not a JSON object." };
|
|
2433
|
+
}
|
|
2434
|
+
const attestation = parsed;
|
|
2435
|
+
const ruleId = typeof attestation.rule_id === "string" ? attestation.rule_id : undefined;
|
|
2436
|
+
if (!ruleId) {
|
|
2437
|
+
return { status: "invalid", reason: "Attestation file is missing rule_id." };
|
|
2438
|
+
}
|
|
2439
|
+
if (typeof attestation.payload_hash !== "string" || attestation.payload_hash.length === 0) {
|
|
2440
|
+
return { status: "invalid", rule_id: ruleId, reason: "Attestation file is missing payload_hash." };
|
|
2441
|
+
}
|
|
2442
|
+
const { payload_hash, ...withoutHash } = attestation;
|
|
2443
|
+
if (digestJson(withoutHash) !== payload_hash) {
|
|
2444
|
+
return { status: "invalid", rule_id: ruleId, reason: "Attestation payload_hash does not match the file contents." };
|
|
2445
|
+
}
|
|
2446
|
+
return { status: "valid", attestation };
|
|
2447
|
+
}
|
|
1629
2448
|
async function readJsonIfExists(filePath) {
|
|
1630
2449
|
try {
|
|
1631
2450
|
if (!(await stat(filePath)).isFile()) {
|
|
@@ -1669,15 +2488,77 @@ function sidecarReadme(compliance) {
|
|
|
1669
2488
|
"",
|
|
1670
2489
|
].join("\n");
|
|
1671
2490
|
}
|
|
1672
|
-
function sidecarDir(repoRoot) {
|
|
1673
|
-
return path.join(repoRoot, complianceDirName);
|
|
1674
|
-
}
|
|
1675
2491
|
function attestationsDir(repoRoot) {
|
|
1676
2492
|
return path.join(sidecarDir(repoRoot), attestationsDirName);
|
|
1677
2493
|
}
|
|
1678
2494
|
function compliancePath(repoRoot) {
|
|
1679
2495
|
return path.join(sidecarDir(repoRoot), "compliance.json");
|
|
1680
2496
|
}
|
|
2497
|
+
const BASELINE_NON_GREEN = new Set(["deterministic-fail", "attestation-needed", "stale", "blocked"]);
|
|
2498
|
+
function baselineKeyFor(result) {
|
|
2499
|
+
const ruleId = result.contractRuleId ?? result.ruleId;
|
|
2500
|
+
return `${ruleId}@${result.finding?.file ?? "*"}`;
|
|
2501
|
+
}
|
|
2502
|
+
function baselinePath(repoRoot) {
|
|
2503
|
+
return path.join(sidecarDir(repoRoot), "baseline.json");
|
|
2504
|
+
}
|
|
2505
|
+
async function readBaseline(repoRoot) {
|
|
2506
|
+
return readJsonIfExists(baselinePath(repoRoot));
|
|
2507
|
+
}
|
|
2508
|
+
function deriveGate(flags) {
|
|
2509
|
+
if (flags.hasBlocked)
|
|
2510
|
+
return { status: "blocked", exitCode: 3 };
|
|
2511
|
+
if (flags.hasDeterministicFailure)
|
|
2512
|
+
return { status: "deterministic-failures", exitCode: 2 };
|
|
2513
|
+
if (flags.needsAttestation)
|
|
2514
|
+
return { status: "needs-attestation", exitCode: 1 };
|
|
2515
|
+
if (flags.hasCompletenessGap)
|
|
2516
|
+
return { status: "completeness-gap", exitCode: 5 };
|
|
2517
|
+
if (flags.hasSelectedOptionalFailures)
|
|
2518
|
+
return { status: "selected-capability-failures", exitCode: 6 };
|
|
2519
|
+
return { status: "green", exitCode: 0 };
|
|
2520
|
+
}
|
|
2521
|
+
export async function recordBaseline(repoPath) {
|
|
2522
|
+
const repoRoot = path.resolve(repoPath);
|
|
2523
|
+
const compliance = await readCompliance(repoRoot);
|
|
2524
|
+
const check = await checkCompliance(repoPath);
|
|
2525
|
+
if (check.status === "contract-drift") {
|
|
2526
|
+
throw new Error("Cannot record a baseline while the contract is drifted (run `vise init` to refresh the sidecar first).");
|
|
2527
|
+
}
|
|
2528
|
+
const findings = {};
|
|
2529
|
+
for (const result of check.rules) {
|
|
2530
|
+
if (!BASELINE_NON_GREEN.has(result.status))
|
|
2531
|
+
continue;
|
|
2532
|
+
const key = baselineKeyFor(result);
|
|
2533
|
+
findings[key] = (findings[key] ?? 0) + 1;
|
|
2534
|
+
}
|
|
2535
|
+
const completeness_missing = (check.completeness?.missing ?? []).map((item) => item.id);
|
|
2536
|
+
const selected_optional = [
|
|
2537
|
+
...(check.selectedOptionalCapabilities?.failed ?? []).map((item) => item.id),
|
|
2538
|
+
...(check.selectedOptionalCapabilities?.unknown ?? []),
|
|
2539
|
+
];
|
|
2540
|
+
const baseline = {
|
|
2541
|
+
baselined_at: new Date().toISOString(),
|
|
2542
|
+
vise_version: packageVersion,
|
|
2543
|
+
outcome: compliance.outcome,
|
|
2544
|
+
ruleset_digest: compliance.ruleset_digest,
|
|
2545
|
+
findings,
|
|
2546
|
+
completeness_missing,
|
|
2547
|
+
selected_optional,
|
|
2548
|
+
};
|
|
2549
|
+
await writeJson(baselinePath(repoRoot), baseline);
|
|
2550
|
+
return {
|
|
2551
|
+
status: "baselined",
|
|
2552
|
+
baseline: complianceDirName + "/baseline.json",
|
|
2553
|
+
outcome: baseline.outcome,
|
|
2554
|
+
recorded: {
|
|
2555
|
+
findings: Object.values(findings).reduce((a, b) => a + b, 0),
|
|
2556
|
+
completeness_missing: completeness_missing.length,
|
|
2557
|
+
selected_optional: selected_optional.length,
|
|
2558
|
+
},
|
|
2559
|
+
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.",
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
1681
2562
|
function experienceReportPath(repoRoot) {
|
|
1682
2563
|
return path.join(sidecarDir(repoRoot), experienceReportFileName);
|
|
1683
2564
|
}
|