@amityco/social-plus-vise 1.3.0 → 1.4.1
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 +51 -2
- package/README.md +36 -19
- package/dist/capabilities.js +29 -1
- package/dist/entryState.js +71 -0
- package/dist/experience.js +70 -0
- package/dist/explore.js +1 -1
- package/dist/flow.js +382 -0
- package/dist/humanFormat.js +25 -0
- package/dist/intake.js +117 -0
- package/dist/intelligence/placement.js +2 -1
- package/dist/outcomes.js +126 -28
- package/dist/productExpectations.js +15 -0
- package/dist/requestReadiness.js +99 -0
- package/dist/server.js +566 -19
- package/dist/sidecar.js +5 -0
- package/dist/solutionPath.js +1 -1
- package/dist/tools/compliance.js +752 -125
- package/dist/tools/creative.js +14 -12
- package/dist/tools/design.js +321 -11
- 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 +963 -66
- package/dist/tools/smoke.js +134 -0
- package/dist/tools/uxHarness.js +54 -6
- package/dist/uikitCustomization.js +3 -1
- 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
|
@@ -2,11 +2,16 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { sidecarDir } from "../sidecar.js";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds,
|
|
7
|
-
import { CLASSIFY_ORDER, getOutcomeDefinition,
|
|
7
|
+
import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
|
|
8
|
+
import { CLASSIFY_ORDER, getOutcomeDefinition, planContextFor, resolveOutcome, } from "../outcomes.js";
|
|
8
9
|
import { contractRuleCandidatesForPublicId, hasMultipleContractRuleCandidates, productExpectationBindingForSensor, productExpectationTitle, publicProductRuleId, } from "../productExpectations.js";
|
|
9
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";
|
|
10
15
|
import { packageVersion } from "../version.js";
|
|
11
16
|
import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
|
|
12
17
|
import { installedBlockProvidedCapabilities } from "./blocks.js";
|
|
@@ -335,6 +340,26 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
335
340
|
const creativeSelection = storedCreativeSelection && creativeSelectionAppliesToRequest(storedCreativeSelection, request)
|
|
336
341
|
? storedCreativeSelection
|
|
337
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
|
+
}
|
|
338
363
|
const uxHarness = creativeSelection
|
|
339
364
|
? await buildUxHarness({ repoPath: repoRoot, surfacePath, selection: creativeSelection, write: true })
|
|
340
365
|
: undefined;
|
|
@@ -356,15 +381,31 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
356
381
|
designSignals: inspection.designSignals,
|
|
357
382
|
});
|
|
358
383
|
if (intake.remainingBlocking > 0 && !intake.acknowledged_unresolved_blocking) {
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
.map((question) => question.id);
|
|
384
|
+
const blockingQuestions = intake.questions.filter((question) => question.blocksImplementationWhenMissing);
|
|
385
|
+
const blockingIds = blockingQuestions.map((question) => question.id);
|
|
362
386
|
const answerExample = blockingIds.map((id) => `${id}=<value>`).join(" --answer ");
|
|
363
387
|
return {
|
|
364
388
|
status: "needs-clarification",
|
|
365
389
|
exitCode: 7,
|
|
366
390
|
outcome,
|
|
367
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
|
+
},
|
|
368
409
|
...(intakeWarnings.length > 0 && { warnings: intakeWarnings }),
|
|
369
410
|
nextStep: "Run `vise plan` and surface the blocking intake questions to the customer. " +
|
|
370
411
|
`Then re-run with their answers (one --answer per id): vise init --request <request> --answer ${answerExample}. ` +
|
|
@@ -408,6 +449,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
408
449
|
});
|
|
409
450
|
await writeJson(path.join(sidecarDir(repoRoot), "inspection.json"), inspection);
|
|
410
451
|
await writeFile(path.join(sidecarDir(repoRoot), "README.md"), sidecarReadme(compliance), "utf8");
|
|
452
|
+
const runtimeSmokeTemplate = await writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome);
|
|
411
453
|
const checkSnapshot = await checkCompliance(repoPath);
|
|
412
454
|
await writeJson(path.join(sidecarDir(repoRoot), "findings.json"), {
|
|
413
455
|
snapshot_at: compliance.generated_at,
|
|
@@ -434,6 +476,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
434
476
|
...(uxHarness ? { ux_harness: uxHarnessSummary(uxHarness) } : {}),
|
|
435
477
|
...(compliance.design_contract && { design_contract: compliance.design_contract }),
|
|
436
478
|
...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
|
|
479
|
+
...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
|
|
437
480
|
intake: {
|
|
438
481
|
status: intake.status,
|
|
439
482
|
remainingBlocking: intake.remainingBlocking,
|
|
@@ -442,7 +485,127 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
442
485
|
answers: intake.answers,
|
|
443
486
|
},
|
|
444
487
|
...(warnings.length > 0 && { warnings }),
|
|
445
|
-
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),
|
|
446
609
|
};
|
|
447
610
|
}
|
|
448
611
|
function intakeAuditFor(args) {
|
|
@@ -455,73 +618,17 @@ function intakeAuditFor(args) {
|
|
|
455
618
|
designSignals: args.designSignals,
|
|
456
619
|
answers: args.answers,
|
|
457
620
|
});
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
if (args.designReview?.status === "rejected" && !hasAnswer(ctx.answers, "design_source")) {
|
|
470
|
-
questions.push({
|
|
471
|
-
id: "design_source",
|
|
472
|
-
question: "What design source should replace the rejected preview/contract?",
|
|
473
|
-
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.",
|
|
474
|
-
required: true,
|
|
475
|
-
blocksImplementationWhenMissing: true,
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
|
|
479
|
-
questions.push({
|
|
480
|
-
id: "design_source",
|
|
481
|
-
question: "Where are the app's design tokens, theme, or reusable UI components defined?",
|
|
482
|
-
why: "The user asked to match the existing design, and no local design source was detected.",
|
|
483
|
-
required: true,
|
|
484
|
-
blocksImplementationWhenMissing: true,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
if (ctx.mentionsDesign && ctx.designSignals.length > 0 && !hasAnswer(ctx.answers, "confirm_design_source")) {
|
|
488
|
-
questions.push({
|
|
489
|
-
id: "confirm_design_source",
|
|
490
|
-
question: `Should the social UI use the detected design source(s): ${ctx.designSignals.map((signal) => signal.file).join(", ")}?`,
|
|
491
|
-
why: "Vise found likely design evidence, but the user or host agent should confirm it is the right source before UI edits.",
|
|
492
|
-
required: true,
|
|
493
|
-
blocksImplementationWhenMissing: false,
|
|
494
|
-
options: ["yes", "use another source"],
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
if (args.designBrief &&
|
|
498
|
-
args.designReview?.status === "accepted" &&
|
|
499
|
-
(args.outcome === "add-feed" || args.outcome === "add-chat") &&
|
|
500
|
-
!args.designBrief.roles.some((role) => role.role === "primaryAction") &&
|
|
501
|
-
!hasAnswer(ctx.answers, "primary_action_token")) {
|
|
502
|
-
questions.push({
|
|
503
|
-
id: "primary_action_token",
|
|
504
|
-
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.",
|
|
505
|
-
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.",
|
|
506
|
-
required: false,
|
|
507
|
-
blocksImplementationWhenMissing: false,
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
const availableOptionalIds = availableOptionalCapabilityIds(args.capabilityAvailability);
|
|
511
|
-
const optionalChoices = args.outcome === "add-feed" ? optionalCapabilityChecklist(args.outcome, availableOptionalIds) : [];
|
|
512
|
-
if (args.outcome === "add-feed" && optionalChoices.length > 0 && !hasAnswer(ctx.answers, "feed_optional_capabilities")) {
|
|
513
|
-
questions.push({
|
|
514
|
-
id: "feed_optional_capabilities",
|
|
515
|
-
question: `Which optional feed capabilities should be in scope on ${platform}: ${optionalChoices.map((capability) => capability.id).join(", ")}, or none?`,
|
|
516
|
-
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.",
|
|
517
|
-
required: false,
|
|
518
|
-
blocksImplementationWhenMissing: false,
|
|
519
|
-
options: ["none", ...optionalChoices.map((capability) => capability.id)],
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
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
|
+
});
|
|
523
630
|
return {
|
|
524
|
-
status
|
|
631
|
+
status,
|
|
525
632
|
questions,
|
|
526
633
|
answers: args.answers,
|
|
527
634
|
remainingBlocking,
|
|
@@ -755,17 +862,42 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
755
862
|
const recordedPlatforms = compliance.surface?.platforms || [];
|
|
756
863
|
const platformsToValidate = Array.from(new Set([...detectedPlatforms, ...recordedPlatforms]));
|
|
757
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
|
+
}
|
|
758
886
|
const { canonicalPlatform } = await import("./sdkFacts.js");
|
|
759
887
|
const allFindings = await Promise.all(platforms.map((p) => validateSetup(inspection.effectiveRoot, p)));
|
|
760
888
|
const findings = allFindings.flat();
|
|
761
889
|
const findingsById = new Map();
|
|
890
|
+
const findingsByIdAll = new Map();
|
|
762
891
|
for (const finding of findings) {
|
|
763
892
|
findingsById.set(finding.ruleId, finding);
|
|
893
|
+
appendFinding(findingsByIdAll, finding.ruleId, finding);
|
|
764
894
|
if (finding.sensorId) {
|
|
765
895
|
findingsById.set(finding.sensorId, finding);
|
|
896
|
+
appendFinding(findingsByIdAll, finding.sensorId, finding);
|
|
766
897
|
}
|
|
767
898
|
}
|
|
768
899
|
const attestations = await readAttestations(repoRoot);
|
|
900
|
+
const attestationHygiene = await readAttestationHygiene(repoRoot, compliance);
|
|
769
901
|
const results = [];
|
|
770
902
|
for (const ref of compliance.rules) {
|
|
771
903
|
const rule = rules.get(ref.rule_id);
|
|
@@ -794,20 +926,18 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
794
926
|
}
|
|
795
927
|
const hasDeterministicChecks = (rule.enforcement.deterministic ?? []).length > 0;
|
|
796
928
|
const isInferential = !hasDeterministicChecks && !!rule.enforcement.inferential;
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
? "undetected-platform"
|
|
803
|
-
: "absent";
|
|
929
|
+
const deterministic = hasDeterministicChecks
|
|
930
|
+
? await assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot)
|
|
931
|
+
: undefined;
|
|
932
|
+
const finding = deterministic?.finding;
|
|
933
|
+
if (hasDeterministicChecks && deterministic?.passed) {
|
|
804
934
|
results.push({
|
|
805
|
-
...
|
|
935
|
+
...checkRuleIdentity(rule.id),
|
|
806
936
|
title: rule.title,
|
|
807
937
|
severity: rule.severity,
|
|
808
938
|
status: "deterministic-pass",
|
|
809
|
-
pass_basis:
|
|
810
|
-
evidence:
|
|
939
|
+
pass_basis: deterministic.pass_basis,
|
|
940
|
+
evidence: deterministic.evidence,
|
|
811
941
|
});
|
|
812
942
|
continue;
|
|
813
943
|
}
|
|
@@ -823,10 +953,10 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
823
953
|
reason: rule.advisory
|
|
824
954
|
? "Advisory: informational only — does not affect compliance status."
|
|
825
955
|
: rule.enforcement.attestation.allowed
|
|
826
|
-
?
|
|
827
|
-
: "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."),
|
|
828
958
|
finding,
|
|
829
|
-
recommendation: finding?.recommendation,
|
|
959
|
+
recommendation: finding?.recommendation ?? deterministic?.recommendation,
|
|
830
960
|
rationale: rule.rationale,
|
|
831
961
|
current_rule: ruleSummary(rule),
|
|
832
962
|
...(failStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
@@ -857,6 +987,29 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
857
987
|
});
|
|
858
988
|
continue;
|
|
859
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
|
+
}
|
|
860
1013
|
const deprecated = grandfathered && (rule.deprecated_versions ?? []).includes(attestation.rule_version);
|
|
861
1014
|
results.push({
|
|
862
1015
|
...checkRuleIdentity(rule.id),
|
|
@@ -886,6 +1039,9 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
886
1039
|
else if (isInferential) {
|
|
887
1040
|
fallbackReason = "Inferential check required. Please provide a host-agent attestation.";
|
|
888
1041
|
}
|
|
1042
|
+
else if (deterministic?.reason) {
|
|
1043
|
+
fallbackReason = deterministic.reason;
|
|
1044
|
+
}
|
|
889
1045
|
else if (rule.enforcement.attestation.allowed) {
|
|
890
1046
|
fallbackReason = "Deterministic check failed and no valid attestation exists.";
|
|
891
1047
|
}
|
|
@@ -896,7 +1052,7 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
896
1052
|
status: baseStatus,
|
|
897
1053
|
reason: fallbackReason,
|
|
898
1054
|
finding,
|
|
899
|
-
recommendation: finding?.recommendation,
|
|
1055
|
+
recommendation: finding?.recommendation ?? deterministic?.recommendation,
|
|
900
1056
|
current_rule: ruleSummary(rule),
|
|
901
1057
|
...(isInferential && { inferential_prompt: rule.enforcement.inferential?.prompt }),
|
|
902
1058
|
...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
@@ -921,16 +1077,20 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
921
1077
|
}
|
|
922
1078
|
const summary = summarize(results);
|
|
923
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;
|
|
924
1083
|
const evidenceBasis = deterministicPasses.length > 0
|
|
925
1084
|
? {
|
|
926
|
-
absent:
|
|
927
|
-
undetected_platform:
|
|
1085
|
+
absent: absencePasses,
|
|
1086
|
+
undetected_platform: undetectedPlatformPasses,
|
|
1087
|
+
...(runtimeSmokePasses > 0 ? { runtime_smoke: runtimeSmokePasses } : {}),
|
|
928
1088
|
}
|
|
929
1089
|
: undefined;
|
|
930
1090
|
const evidenceBasisNote = evidenceBasis
|
|
931
|
-
? `${
|
|
932
|
-
? ` ${
|
|
933
|
-
: ""} Treat
|
|
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".`
|
|
934
1094
|
: undefined;
|
|
935
1095
|
const sourceCompleteness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
|
|
936
1096
|
const blockProvided = sourceCompleteness && sourceCompleteness.missing.length > 0
|
|
@@ -997,14 +1157,18 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
997
1157
|
});
|
|
998
1158
|
const uxHarness = await readUxHarness(repoRoot);
|
|
999
1159
|
const uxAssessment = await assessUxHarness(inspection.effectiveRoot, uxHarness, compliance.outcome);
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
+
};
|
|
1008
1172
|
return {
|
|
1009
1173
|
status,
|
|
1010
1174
|
exitCode,
|
|
@@ -1019,6 +1183,7 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
1019
1183
|
...(baselineReport ? { baseline: baselineReport } : {}),
|
|
1020
1184
|
uxHarness: uxAssessment,
|
|
1021
1185
|
experienceReport,
|
|
1186
|
+
...(attestationHygiene ? { attestation_hygiene: attestationHygiene } : {}),
|
|
1022
1187
|
};
|
|
1023
1188
|
}
|
|
1024
1189
|
export async function generateExperienceReport(repoPath, options = {}) {
|
|
@@ -1086,6 +1251,92 @@ function sensorReviewGaps(raw) {
|
|
|
1086
1251
|
}
|
|
1087
1252
|
return gaps.slice(0, 50);
|
|
1088
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
|
+
}
|
|
1089
1340
|
function fallbackExperienceReport(check) {
|
|
1090
1341
|
const uxAssessment = check.uxHarness ?? {
|
|
1091
1342
|
status: "absent",
|
|
@@ -1136,10 +1387,12 @@ export async function syncCompliance(repoPath) {
|
|
|
1136
1387
|
continue;
|
|
1137
1388
|
}
|
|
1138
1389
|
const passBasis = result.pass_basis ?? "absent";
|
|
1139
|
-
const confidence = passBasis === "undetected-platform" ? "low" : "medium";
|
|
1140
|
-
const rationale = passBasis === "
|
|
1141
|
-
? "Deterministic check passed
|
|
1142
|
-
:
|
|
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.";
|
|
1143
1396
|
const attestation = buildAttestation(compliance, rule, "spf-deterministic", confidence, undefined, rationale, { ...(result.evidence ?? {}), pass_basis: passBasis });
|
|
1144
1397
|
await writeJson(filePath, attestation);
|
|
1145
1398
|
written.push(filePath);
|
|
@@ -1159,6 +1412,7 @@ export async function syncCompliance(repoPath) {
|
|
|
1159
1412
|
written,
|
|
1160
1413
|
removed,
|
|
1161
1414
|
last_synced_at: compliance.last_synced_at,
|
|
1415
|
+
...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
|
|
1162
1416
|
};
|
|
1163
1417
|
}
|
|
1164
1418
|
export async function attestRule(args) {
|
|
@@ -1202,10 +1456,16 @@ export async function attestRule(args) {
|
|
|
1202
1456
|
throw new Error(`Rule does not allow local human attestations: ${args.ruleId}`);
|
|
1203
1457
|
}
|
|
1204
1458
|
validateEvidence(rule, args.evidence);
|
|
1205
|
-
const sensorWarning = await liveSensorContradiction(repoRoot, compliance, rule);
|
|
1459
|
+
const { warning: sensorWarning, violatorFiles } = await liveSensorContradiction(repoRoot, compliance, rule);
|
|
1206
1460
|
await mkdir(attestationsDir(repoRoot), { recursive: true });
|
|
1207
|
-
const
|
|
1208
|
-
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);
|
|
1209
1469
|
const filePath = path.join(attestationsDir(repoRoot), attestationPathFor(contractRuleId));
|
|
1210
1470
|
await writeJson(filePath, attestation);
|
|
1211
1471
|
return {
|
|
@@ -1214,6 +1474,7 @@ export async function attestRule(args) {
|
|
|
1214
1474
|
destination: filePath,
|
|
1215
1475
|
signer_claim: attestation.signer_claim,
|
|
1216
1476
|
source_fingerprints: sourceFingerprints,
|
|
1477
|
+
...(fileScoped ? { file_scoped: true, scoped_files: [...new Set(sourceFingerprints.map((fp) => fp.path))] } : {}),
|
|
1217
1478
|
...(sensorWarning ? { sensor_warning: sensorWarning } : {}),
|
|
1218
1479
|
};
|
|
1219
1480
|
}
|
|
@@ -1321,6 +1582,7 @@ export async function statusCompliance(repoPath, options = {}) {
|
|
|
1321
1582
|
...(check.evidence_basis_note ? { evidence_basis_note: check.evidence_basis_note } : {}),
|
|
1322
1583
|
...(check.completeness ? { completeness: check.completeness } : {}),
|
|
1323
1584
|
...(check.selectedOptionalCapabilities ? { selectedOptionalCapabilities: check.selectedOptionalCapabilities } : {}),
|
|
1585
|
+
...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
|
|
1324
1586
|
...(compliance?.design_contract && { design_contract: compliance.design_contract }),
|
|
1325
1587
|
...(engagement && {
|
|
1326
1588
|
engagement: {
|
|
@@ -1490,6 +1752,187 @@ function contractDrift(compliance, rules) {
|
|
|
1490
1752
|
}
|
|
1491
1753
|
return results;
|
|
1492
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
|
+
}
|
|
1493
1936
|
function deterministicFinding(rule, findingsById) {
|
|
1494
1937
|
for (const id of deterministicFindingIds(rule)) {
|
|
1495
1938
|
const finding = findingsById.get(id);
|
|
@@ -1501,32 +1944,39 @@ function deterministicFinding(rule, findingsById) {
|
|
|
1501
1944
|
}
|
|
1502
1945
|
async function liveSensorContradiction(repoRoot, compliance, rule) {
|
|
1503
1946
|
if (deterministicFindingIds(rule).length === 0) {
|
|
1504
|
-
return
|
|
1947
|
+
return { violatorFiles: [] };
|
|
1505
1948
|
}
|
|
1506
1949
|
const inspection = await inspectProject(repoRoot, compliance.surface?.path === "." ? undefined : compliance.surface?.path);
|
|
1507
1950
|
const platforms = Array.from(new Set([...inspection.platforms, ...(compliance.surface?.platforms ?? [])]));
|
|
1508
1951
|
if (platforms.length === 0) {
|
|
1509
|
-
return
|
|
1952
|
+
return { violatorFiles: [] };
|
|
1510
1953
|
}
|
|
1511
1954
|
const allFindings = await Promise.all(platforms.map((platform) => validateSetup(inspection.effectiveRoot, platform)));
|
|
1512
1955
|
const findingsById = new Map();
|
|
1956
|
+
const findingsByIdAll = new Map();
|
|
1513
1957
|
for (const finding of allFindings.flat()) {
|
|
1514
1958
|
findingsById.set(finding.ruleId, finding);
|
|
1959
|
+
appendFinding(findingsByIdAll, finding.ruleId, finding);
|
|
1515
1960
|
if (finding.sensorId) {
|
|
1516
1961
|
findingsById.set(finding.sensorId, finding);
|
|
1962
|
+
appendFinding(findingsByIdAll, finding.sensorId, finding);
|
|
1517
1963
|
}
|
|
1518
1964
|
}
|
|
1519
1965
|
const finding = deterministicFinding(rule, findingsById);
|
|
1520
1966
|
if (!finding) {
|
|
1521
|
-
return
|
|
1967
|
+
return { violatorFiles: [] };
|
|
1522
1968
|
}
|
|
1969
|
+
const violatorFiles = await ruleViolatorFiles(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll);
|
|
1523
1970
|
return {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
+
},
|
|
1529
1978
|
},
|
|
1979
|
+
violatorFiles,
|
|
1530
1980
|
};
|
|
1531
1981
|
}
|
|
1532
1982
|
function deterministicFindingIds(rule) {
|
|
@@ -1534,6 +1984,117 @@ function deterministicFindingIds(rule) {
|
|
|
1534
1984
|
.filter((check) => check.check === "validator-finding-absent")
|
|
1535
1985
|
.map((check) => check.finding_rule_id);
|
|
1536
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
|
+
}
|
|
1537
2098
|
function uniqueStrings(values) {
|
|
1538
2099
|
const seen = new Set();
|
|
1539
2100
|
const result = [];
|
|
@@ -1546,7 +2107,7 @@ function uniqueStrings(values) {
|
|
|
1546
2107
|
}
|
|
1547
2108
|
return result;
|
|
1548
2109
|
}
|
|
1549
|
-
function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = []) {
|
|
2110
|
+
function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = [], fileScoped = false) {
|
|
1550
2111
|
const ref = compliance.rules.find((item) => item.rule_id === rule.id);
|
|
1551
2112
|
if (!ref) {
|
|
1552
2113
|
throw new Error(`Rule is not in compliance contract: ${rule.id}`);
|
|
@@ -1568,6 +2129,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
|
|
|
1568
2129
|
signature: null,
|
|
1569
2130
|
evidence,
|
|
1570
2131
|
source_fingerprints: sourceFingerprints,
|
|
2132
|
+
...(fileScoped ? { file_scoped: true } : {}),
|
|
1571
2133
|
rationale_for_attestation: rationale,
|
|
1572
2134
|
attested_at: new Date().toISOString(),
|
|
1573
2135
|
};
|
|
@@ -1716,13 +2278,19 @@ export function ruleSummary(rule) {
|
|
|
1716
2278
|
pattern: "pattern" in blocker ? blocker.pattern : undefined,
|
|
1717
2279
|
reason: blocker.reason,
|
|
1718
2280
|
})),
|
|
1719
|
-
deterministic_check_ids: (rule.enforcement.deterministic ?? []).map(
|
|
2281
|
+
deterministic_check_ids: (rule.enforcement.deterministic ?? []).map(deterministicCheckId),
|
|
1720
2282
|
attestation_allowed: rule.enforcement.attestation.allowed,
|
|
1721
2283
|
host_agent_min_confidence: rule.enforcement.attestation.host_agent_min_confidence,
|
|
1722
2284
|
human_allowed: rule.enforcement.attestation.human_allowed !== false,
|
|
1723
2285
|
evidence_field_names: (rule.enforcement.attestation.evidence_required ?? []).map((field) => field.field),
|
|
1724
2286
|
};
|
|
1725
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
|
+
}
|
|
1726
2294
|
export async function runBlockers(rule, root) {
|
|
1727
2295
|
const fired = [];
|
|
1728
2296
|
for (const blocker of rule.enforcement.blockers ?? []) {
|
|
@@ -1805,16 +2373,78 @@ async function readAttestations(repoRoot) {
|
|
|
1805
2373
|
if (!entry.endsWith(".json")) {
|
|
1806
2374
|
continue;
|
|
1807
2375
|
}
|
|
1808
|
-
const
|
|
1809
|
-
if (
|
|
1810
|
-
|
|
1811
|
-
if (digestJson(withoutHash) === payload_hash) {
|
|
1812
|
-
result.set(attestation.rule_id, attestation);
|
|
1813
|
-
}
|
|
2376
|
+
const candidate = await readAttestationCandidate(path.join(dir, entry));
|
|
2377
|
+
if (candidate.status === "valid") {
|
|
2378
|
+
result.set(candidate.attestation.rule_id, candidate.attestation);
|
|
1814
2379
|
}
|
|
1815
2380
|
}
|
|
1816
2381
|
return result;
|
|
1817
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
|
+
}
|
|
1818
2448
|
async function readJsonIfExists(filePath) {
|
|
1819
2449
|
try {
|
|
1820
2450
|
if (!(await stat(filePath)).isFile()) {
|
|
@@ -1858,9 +2488,6 @@ function sidecarReadme(compliance) {
|
|
|
1858
2488
|
"",
|
|
1859
2489
|
].join("\n");
|
|
1860
2490
|
}
|
|
1861
|
-
function sidecarDir(repoRoot) {
|
|
1862
|
-
return path.join(repoRoot, complianceDirName);
|
|
1863
|
-
}
|
|
1864
2491
|
function attestationsDir(repoRoot) {
|
|
1865
2492
|
return path.join(sidecarDir(repoRoot), attestationsDirName);
|
|
1866
2493
|
}
|