@dailephd/my-dev-kit-lab 0.6.2 → 0.6.3
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 +18 -0
- package/README.md +9 -8
- package/dist/src/cli/help.js +16 -9
- package/dist/src/commands/runExperimentDescribeCommand.js +5 -2
- package/dist/src/commands/runExperimentRunCommand.js +0 -4
- package/dist/src/evaluation/benchmarkMetadata.js +5 -1
- package/dist/src/evaluation/parseAgentAnswer.js +172 -24
- package/dist/src/evaluation/runMyDevKitRetrieval.js +120 -7
- package/dist/src/experiments/plugins/incrementalChangeStaleness/comparisonV2.js +49 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +5 -4
- package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +20 -2
- package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +42 -16
- package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +4 -4
- package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifactV2.js +289 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/executionV2.js +195 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/indexClone.js +76 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +64 -32
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +6 -4
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicyV2.js +78 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycleV2.js +298 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +213 -12
- package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSessionV2.js +5 -0
- package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +3 -1
- package/dist/src/mobile/android/advancedSecurity/candidateEvidence.js +9 -2
- package/dist/src/mobile/android/advancedSecurity/redaction.js +19 -10
- package/dist/src/mobile/android/advancedSecurity/secretCandidates/analyzeSecretCandidates.js +12 -2
- package/dist/src/mobile/android/advancedSecurity/signingConfiguration/analyzeSigningConfiguration.js +1 -1
- package/dist/src/mobile/android/advancedSecurity/signingConfiguration/extractSigningConfigurations.js +7 -7
- package/dist/src/report/experiments/buildIncrementalChangeStalenessPluginReport.js +33 -0
- package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +1 -1
- package/dist/src/report/experiments/buildIncrementalChangeStalenessReportV2.js +80 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +2 -2
- package/dist/src/report/experiments/incrementalChangeStalenessReportModelV2.js +3 -0
- package/dist/src/report/experiments/incrementalChangeStalenessReportRowsV2.js +146 -0
- package/dist/src/report/experiments/index.js +3 -0
- package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +4 -0
- package/dist/src/report/experiments/renderIncrementalChangeStalenessHtmlV2.js +111 -0
- package/dist/src/report/experiments/renderIncrementalChangeStalenessTextV2.js +96 -0
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +6 -0
- package/dist/src/securityValidation/staticScans/codeql.js +8 -8
- package/docs/METRICS.md +32 -0
- package/examples/tutorial-browser/prepare.mjs +13 -13
- package/examples/tutorial-browser/server.mjs +13 -13
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { scryptSync } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { stripUnsafeControlChars } from "../../../securityValidation/attackScenarios/exploitEvidence.js";
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// v0.4.1 Batch 1 — redaction and fingerprint primitives for later Android
|
|
@@ -13,8 +14,8 @@ import { stripUnsafeControlChars } from "../../../securityValidation/attackScena
|
|
|
13
14
|
// payload). Android candidate evidence has a different, narrower need: a
|
|
14
15
|
// human reviewing a static-candidate finding benefits from a short prefix/
|
|
15
16
|
// suffix so they can recognize *which* secret in their own source it is
|
|
16
|
-
// without the full value
|
|
17
|
-
//
|
|
17
|
+
// without retaining the full value — so this module adds that
|
|
18
|
+
// prefix/suffix-preserving preview and a one-way fingerprint on
|
|
18
19
|
// top of the shared control-character stripping, instead of creating a
|
|
19
20
|
// second, competing full-redaction implementation.
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -42,13 +43,21 @@ export function redactedPreviewForCandidate(input, options = {}) {
|
|
|
42
43
|
const suffix = bounded.slice(-edgeLength);
|
|
43
44
|
return `${prefix}${MASK}${suffix}`;
|
|
44
45
|
}
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
export function fingerprintCandidateValue(input) {
|
|
46
|
+
// Deterministic, one-way evidence identity. Scrypt makes guessing more costly
|
|
47
|
+
// than a fast hash, but low-entropy values are not cryptographically
|
|
48
|
+
// unrecoverable. The context salt separates equivalent values at distinct
|
|
49
|
+
// evidence locations without storing a secret salt or the raw value.
|
|
50
|
+
export function fingerprintCandidateValue(input, context) {
|
|
50
51
|
if (input === undefined)
|
|
51
52
|
return "unavailable";
|
|
52
|
-
const
|
|
53
|
-
|
|
53
|
+
const contextSalt = Buffer.from(JSON.stringify([
|
|
54
|
+
"my-dev-kit-lab/android-candidate-fingerprint/v2",
|
|
55
|
+
context.ruleId,
|
|
56
|
+
path.posix.normalize(context.sourcePath.replaceAll("\\", "/")),
|
|
57
|
+
context.line ?? null,
|
|
58
|
+
context.column ?? null,
|
|
59
|
+
context.purpose
|
|
60
|
+
]), "utf8");
|
|
61
|
+
const derived = scryptSync(input, contextSalt, 32, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
62
|
+
return `scrypt-v1:${derived.toString("hex")}`;
|
|
54
63
|
}
|
package/dist/src/mobile/android/advancedSecurity/secretCandidates/analyzeSecretCandidates.js
CHANGED
|
@@ -51,7 +51,6 @@ export function analyzeSecretCandidateFile(targetRoot, file) {
|
|
|
51
51
|
const fileKind = fileKindFor(file.relativePath);
|
|
52
52
|
for (const block of matchPrivateKeyBlocks(file.content)) {
|
|
53
53
|
const location = buildAndroidSourceLocation(targetRoot, file.absolutePath, { line: block.line });
|
|
54
|
-
const fingerprint = fingerprintCandidateValue(block.rawBlock);
|
|
55
54
|
if (!block.terminated) {
|
|
56
55
|
candidates.push(makeCandidateEvidence({
|
|
57
56
|
ruleId: "android-secret-private-key-candidate",
|
|
@@ -66,6 +65,12 @@ export function analyzeSecretCandidateFile(targetRoot, file) {
|
|
|
66
65
|
}));
|
|
67
66
|
continue;
|
|
68
67
|
}
|
|
68
|
+
const fingerprint = fingerprintCandidateValue(block.rawBlock, {
|
|
69
|
+
ruleId: "android-secret-private-key-candidate",
|
|
70
|
+
sourcePath: location.path,
|
|
71
|
+
line: location.line,
|
|
72
|
+
purpose: "private-key-finding"
|
|
73
|
+
});
|
|
69
74
|
findings.push(makeAndroidFinding({
|
|
70
75
|
ruleId: "android-secret-private-key-candidate",
|
|
71
76
|
title: `Private key material found (${block.headerLabel})`,
|
|
@@ -98,7 +103,12 @@ export function analyzeSecretCandidateFile(targetRoot, file) {
|
|
|
98
103
|
}));
|
|
99
104
|
continue;
|
|
100
105
|
}
|
|
101
|
-
const fingerprint = fingerprintCandidateValue(match.rawValue
|
|
106
|
+
const fingerprint = fingerprintCandidateValue(match.rawValue, {
|
|
107
|
+
ruleId: classification.ruleId,
|
|
108
|
+
sourcePath: location.path,
|
|
109
|
+
line: location.line,
|
|
110
|
+
purpose: `credential-finding:${classification.sensitiveDataCategory}`
|
|
111
|
+
});
|
|
102
112
|
findings.push(makeAndroidFinding({
|
|
103
113
|
ruleId: classification.ruleId,
|
|
104
114
|
title: `Hardcoded ${match.identifier} literal found`,
|
package/dist/src/mobile/android/advancedSecurity/signingConfiguration/analyzeSigningConfiguration.js
CHANGED
|
@@ -149,7 +149,7 @@ export function analyzeModuleSigningConfiguration(targetRoot, gradleModule, buil
|
|
|
149
149
|
const buildFileRelativePath = gradleModule.buildFilePath ?? modulePath;
|
|
150
150
|
const buildFileAbsolutePath = path.join(targetRoot, buildFileRelativePath);
|
|
151
151
|
const location = loc(targetRoot, buildFileAbsolutePath);
|
|
152
|
-
const signingConfigs = extractSigningConfigurations(buildFileText);
|
|
152
|
+
const signingConfigs = extractSigningConfigurations(buildFileText, { sourcePath: location.path });
|
|
153
153
|
const literalCredentialConfigNames = new Set();
|
|
154
154
|
for (const config of signingConfigs) {
|
|
155
155
|
const storePasswordResult = analyzeCredential("storePassword", config.storePassword, config.name, modulePath, location, buildFileRelativePath);
|
|
@@ -44,17 +44,17 @@ function extractStoreFileValue(content) {
|
|
|
44
44
|
return { state: "literal", literalValue: fileWrapped[1] };
|
|
45
45
|
return extractPathValue(content, ["storeFile"]);
|
|
46
46
|
}
|
|
47
|
-
function extractCredentialValue(content, keys) {
|
|
47
|
+
function extractCredentialValue(content, keys, context) {
|
|
48
48
|
const result = extractLiteral(content, keys);
|
|
49
49
|
const state = classifyExpressionState(result);
|
|
50
50
|
if (state === "literal" && result.value !== undefined) {
|
|
51
|
-
return { state, redactedPreview: redactedPreviewForCandidate(result.value), fingerprint: fingerprintCandidateValue(result.value) };
|
|
51
|
+
return { state, redactedPreview: redactedPreviewForCandidate(result.value), fingerprint: fingerprintCandidateValue(result.value, context) };
|
|
52
52
|
}
|
|
53
53
|
if (result.raw !== undefined)
|
|
54
54
|
return { state, rawExpression: boundedRawExpression(result.raw) };
|
|
55
55
|
return { state: "missing" };
|
|
56
56
|
}
|
|
57
|
-
function extractSigningConfigEntry(name, content) {
|
|
57
|
+
function extractSigningConfigEntry(name, content, sourcePath) {
|
|
58
58
|
const v1 = extractBooleanLiteral(content, ["enableV1Signing", "v1SigningEnabled"]);
|
|
59
59
|
const v2 = extractBooleanLiteral(content, ["enableV2Signing", "v2SigningEnabled"]);
|
|
60
60
|
const v3 = extractBooleanLiteral(content, ["enableV3Signing", "v3SigningEnabled"]);
|
|
@@ -62,9 +62,9 @@ function extractSigningConfigEntry(name, content) {
|
|
|
62
62
|
return {
|
|
63
63
|
name,
|
|
64
64
|
storeFile: extractStoreFileValue(content),
|
|
65
|
-
storePassword: extractCredentialValue(content, ["storePassword"]),
|
|
65
|
+
storePassword: extractCredentialValue(content, ["storePassword"], { ruleId: "android-signing-password-literal", sourcePath, purpose: `signing-credential:${name}:storePassword` }),
|
|
66
66
|
keyAlias: extractPathValue(content, ["keyAlias"]),
|
|
67
|
-
keyPassword: extractCredentialValue(content, ["keyPassword"]),
|
|
67
|
+
keyPassword: extractCredentialValue(content, ["keyPassword"], { ruleId: "android-signing-password-literal", sourcePath, purpose: `signing-credential:${name}:keyPassword` }),
|
|
68
68
|
enableV1Signing: v1.value,
|
|
69
69
|
enableV2Signing: v2.value,
|
|
70
70
|
enableV3Signing: v3.value,
|
|
@@ -73,9 +73,9 @@ function extractSigningConfigEntry(name, content) {
|
|
|
73
73
|
}
|
|
74
74
|
// Extracts every named entry inside a module build file's `signingConfigs { }`
|
|
75
75
|
// block. Returns an empty array (never throws) when no such block exists.
|
|
76
|
-
export function extractSigningConfigurations(rawBuildFileText) {
|
|
76
|
+
export function extractSigningConfigurations(rawBuildFileText, context) {
|
|
77
77
|
const text = stripComments(rawBuildFileText);
|
|
78
78
|
return extractNamedSubBlocks(text, "signingConfigs")
|
|
79
|
-
.map(({ name, content }) => extractSigningConfigEntry(name, content))
|
|
79
|
+
.map(({ name, content }) => extractSigningConfigEntry(name, content, context.sourcePath))
|
|
80
80
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
81
81
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Dispatches the incremental-change-staleness plugin report by the persisted execution artifact's
|
|
2
|
+
// schemaVersion: V1 artifacts keep the released V1 report semantics untouched (the V1 builder is called
|
|
3
|
+
// as-is), V2 artifacts select the four-treatment V2 model. Historical V1 evidence is never converted.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { INCREMENTAL_CHANGE_STALENESS_EXECUTION_SCHEMA_VERSION } from "../../experiments/plugins/incrementalChangeStaleness/executionArtifact.js";
|
|
6
|
+
import { INCREMENTAL_CHANGE_STALENESS_EXECUTION_SCHEMA_VERSION_V2 } from "../../experiments/plugins/incrementalChangeStaleness/executionArtifactV2.js";
|
|
7
|
+
import { INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID } from "../../experiments/plugins/incrementalChangeStaleness/plugin.js";
|
|
8
|
+
import { buildIncrementalChangeStalenessReport, readArtifactPath } from "./buildIncrementalChangeStalenessReport.js";
|
|
9
|
+
import { buildIncrementalChangeStalenessReportV2FromArtifact } from "./buildIncrementalChangeStalenessReportV2.js";
|
|
10
|
+
export function buildIncrementalChangeStalenessPluginReport(run) {
|
|
11
|
+
if (run.pluginId !== INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID)
|
|
12
|
+
return null;
|
|
13
|
+
const artifactPath = readArtifactPath(run);
|
|
14
|
+
if (!artifactPath)
|
|
15
|
+
return buildIncrementalChangeStalenessReport(run); // throws the established missing-path error
|
|
16
|
+
let schemaVersion;
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(readFileSync(artifactPath, "utf8"));
|
|
20
|
+
schemaVersion = parsed?.schemaVersion;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Unreadable or malformed: the V1 builder owns the established, explicit error messages.
|
|
24
|
+
return buildIncrementalChangeStalenessReport(run);
|
|
25
|
+
}
|
|
26
|
+
if (schemaVersion === INCREMENTAL_CHANGE_STALENESS_EXECUTION_SCHEMA_VERSION_V2) {
|
|
27
|
+
return buildIncrementalChangeStalenessReportV2FromArtifact(parsed);
|
|
28
|
+
}
|
|
29
|
+
if (schemaVersion === INCREMENTAL_CHANGE_STALENESS_EXECUTION_SCHEMA_VERSION) {
|
|
30
|
+
return buildIncrementalChangeStalenessReport(run);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Invalid incremental-change-staleness report source: unsupported execution artifact schema version ${String(schemaVersion)}.`);
|
|
33
|
+
}
|
|
@@ -74,7 +74,7 @@ function toReportScenario(record) {
|
|
|
74
74
|
comparison: { ...record.comparison, reasonCodes: [...record.comparison.reasonCodes] }
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
function readArtifactPath(run) {
|
|
77
|
+
export function readArtifactPath(run) {
|
|
78
78
|
const fromMetadata = run.metadata?.executionArtifactPath;
|
|
79
79
|
if (typeof fromMetadata === "string" && fromMetadata)
|
|
80
80
|
return fromMetadata;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// v0.6.3 -- projects the persisted V2 incremental-change-staleness execution artifact into the V2 report
|
|
2
|
+
// model. A deterministic projection only: no retrieval, fake-agent evaluation, correctness scoring,
|
|
3
|
+
// affected-neighborhood assessment, freshness assessment, refresh-realization or classification
|
|
4
|
+
// recomputation, and no source/index/benchmark reads. The only integrity check is the artifact's own
|
|
5
|
+
// contradiction validator, so a hand-edited or corrupted artifact is rejected rather than presented.
|
|
6
|
+
import { validateIncrementalChangeStalenessScenarioRecordV2 } from "../../experiments/plugins/incrementalChangeStaleness/executionArtifactV2.js";
|
|
7
|
+
import { INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS } from "../../experiments/plugins/incrementalChangeStaleness/disposableTarget.js";
|
|
8
|
+
import { INCREMENTAL_CHANGE_STALENESS_REPORT_SCHEMA_VERSION_V2 } from "./incrementalChangeStalenessReportModelV2.js";
|
|
9
|
+
// Frozen V2 wording. Every point must appear in report.txt and report.html.
|
|
10
|
+
export const LIMITATIONS_V2 = [
|
|
11
|
+
"Results are scoped to the executed deterministic benchmark scenarios and the configured upstream my-dev-kit tool.",
|
|
12
|
+
"Full refresh is a comparison reference, not an asserted optimal strategy.",
|
|
13
|
+
"A partial treatment that falls back to a full rebuild is not evidence about the requested partial-refresh behavior.",
|
|
14
|
+
"\"no-observed-regression-relative-to-full\" means only that the executed scenario did not show a regression under the frozen correctness and required-file evidence dimensions. It does not prove general equivalence or safety, and does not show that full refresh is unnecessary.",
|
|
15
|
+
"Required-file evidence is a bounded answer-key file-presence check, not retrieval precision/recall.",
|
|
16
|
+
"Correctness remains deterministic fake-agent/harness evidence in v0.6.3.",
|
|
17
|
+
"Lab affected-neighborhood evidence (changed baseline neighborhood relative to the task) is distinct from upstream refresh execution evidence (what my-dev-kit actually refreshed).",
|
|
18
|
+
"\"reindexRecommendation\" remains observational evidence; it did not select or trigger any treatment.",
|
|
19
|
+
"\"changedSymbolCount\" does not mean source-level symbol-diff proof; it retains the released v0.6.1 baseline-symbol-identity definition.",
|
|
20
|
+
"The experiment does not use graph-diff."
|
|
21
|
+
];
|
|
22
|
+
/** Fixed neutral wording per partial-refresh reference classification. Presentation only. */
|
|
23
|
+
export const PARTIAL_REFERENCE_CLASSIFICATION_EXPLANATIONS = {
|
|
24
|
+
"observed-regression-relative-to-full": "The persisted comparison observed at least one regression relative to the full-refresh reference under the frozen evidence dimensions: correctness and/or required-file presence.",
|
|
25
|
+
"no-observed-regression-relative-to-full": "This executed scenario did not show a regression relative to the full-refresh reference under the frozen correctness and required-file evidence dimensions. This does not establish general equivalence or safety, and does not imply that full refresh is unnecessary.",
|
|
26
|
+
inconclusive: "Available persisted evidence was insufficient to determine whether a regression relative to the full-refresh reference occurred. Inconclusive is not the same as no regression.",
|
|
27
|
+
"not-comparable-as-partial-refresh": "The requested partial-refresh treatment fell back to a full rebuild, so this run does not provide evidence about the requested partial-refresh behavior."
|
|
28
|
+
};
|
|
29
|
+
/** Presentation order and labels; identity is always resolved by treatment id, never by array position. */
|
|
30
|
+
export const TREATMENT_PRESENTATION = [
|
|
31
|
+
{ id: "stale-index", label: "No Refresh" },
|
|
32
|
+
{ id: "changed-files-refresh", label: "Changed-Files Refresh" },
|
|
33
|
+
{ id: "affected-neighborhood-refresh", label: "Affected-Neighborhood Refresh" },
|
|
34
|
+
{ id: "full-refresh", label: "Full Refresh" }
|
|
35
|
+
];
|
|
36
|
+
export const COMPARISON_PRESENTATION = [
|
|
37
|
+
{ candidate: "stale-index", label: "No Refresh vs Full Refresh" },
|
|
38
|
+
{ candidate: "changed-files-refresh", label: "Changed-Files Refresh vs Full Refresh" },
|
|
39
|
+
{ candidate: "affected-neighborhood-refresh", label: "Affected-Neighborhood Refresh vs Full Refresh" }
|
|
40
|
+
];
|
|
41
|
+
export function findReportTreatment(scenario, treatmentId) {
|
|
42
|
+
return scenario.treatments.find((treatment) => treatment.treatmentId === treatmentId) ?? null;
|
|
43
|
+
}
|
|
44
|
+
export function findReportComparison(scenario, candidateTreatmentId) {
|
|
45
|
+
return scenario.referenceComparisons.find((comparison) => comparison.candidateTreatmentId === candidateTreatmentId && comparison.referenceTreatmentId === "full-refresh") ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function buildIncrementalChangeStalenessReportV2FromArtifact(artifact) {
|
|
48
|
+
for (const scenario of artifact.scenarios)
|
|
49
|
+
validateIncrementalChangeStalenessScenarioRecordV2(scenario);
|
|
50
|
+
return {
|
|
51
|
+
schemaVersion: INCREMENTAL_CHANGE_STALENESS_REPORT_SCHEMA_VERSION_V2,
|
|
52
|
+
pluginId: artifact.pluginId,
|
|
53
|
+
summary: { ...artifact.summary },
|
|
54
|
+
scenarios: artifact.scenarios.map(toReportScenario),
|
|
55
|
+
limitations: [...LIMITATIONS_V2]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function toReportScenario(record) {
|
|
59
|
+
return {
|
|
60
|
+
scenarioId: record.scenarioId,
|
|
61
|
+
category: record.category,
|
|
62
|
+
benchmarkProjectId: record.benchmarkProjectId,
|
|
63
|
+
baseCaseId: record.baseCaseId,
|
|
64
|
+
status: record.status,
|
|
65
|
+
failureReason: record.failureReason,
|
|
66
|
+
query: record.query,
|
|
67
|
+
answerPolicy: record.answerPolicy,
|
|
68
|
+
expectedFiles: [...record.expectedFiles],
|
|
69
|
+
expectedSymbols: [...record.expectedSymbols],
|
|
70
|
+
lifecycle: record.lifecycle ? structuredClone(record.lifecycle) : null,
|
|
71
|
+
// Persisted order is preserved; consumers resolve identity by id (see findReportTreatment/findReportComparison).
|
|
72
|
+
treatments: record.treatments.map((treatment) => structuredClone(treatment)),
|
|
73
|
+
referenceComparisons: record.referenceComparisons.map((comparison) => structuredClone(comparison))
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Kept for callers that need the fixed id list without importing the plugin layer. */
|
|
77
|
+
export const REPORT_TREATMENT_IDS = INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS;
|
|
78
|
+
export function isV2ReportSection(section) {
|
|
79
|
+
return section !== null && section.schemaVersion === INCREMENTAL_CHANGE_STALENESS_REPORT_SCHEMA_VERSION_V2;
|
|
80
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { buildContextStrategyComparisonV043Report } from "./buildContextStrategyComparisonV043Report.js";
|
|
3
3
|
import { buildWarmIndexReuseReport } from "./buildWarmIndexReuseReport.js";
|
|
4
|
-
import {
|
|
4
|
+
import { buildIncrementalChangeStalenessPluginReport } from "./buildIncrementalChangeStalenessPluginReport.js";
|
|
5
5
|
const V043_BULK_ARRAY_KEYS = [
|
|
6
6
|
"v043StageContextExecutions",
|
|
7
7
|
"v043StageContextEvaluations",
|
|
@@ -12,7 +12,7 @@ export function buildPluginExperimentReport(args) {
|
|
|
12
12
|
const allOutcomes = args.run.cases.flatMap((experimentCase) => experimentCase.outcomes);
|
|
13
13
|
const contextStrategyComparisonV043 = buildContextStrategyComparisonV043Report(args.run);
|
|
14
14
|
const warmIndexReuse = buildWarmIndexReuseReport(args.run);
|
|
15
|
-
const incrementalChangeStaleness =
|
|
15
|
+
const incrementalChangeStaleness = buildIncrementalChangeStalenessPluginReport(args.run);
|
|
16
16
|
const rawRun = { ...args.run, artifacts: relativizeArtifacts(args.run.artifacts, outputRoot) };
|
|
17
17
|
for (const key of V043_BULK_ARRAY_KEYS) {
|
|
18
18
|
delete rawRun[key];
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// v0.6.3 -- plugin-specific presentation model for the four-treatment V2 execution artifact. Types only:
|
|
2
|
+
// projection lives in buildIncrementalChangeStalenessReportV2.ts, rendering in the text/HTML renderers.
|
|
3
|
+
export const INCREMENTAL_CHANGE_STALENESS_REPORT_SCHEMA_VERSION_V2 = "my-dev-kit-lab-incremental-change-staleness-report-v2";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// v0.6.3 -- shared semantic content for the V2 text and HTML renderers, so both present exactly the same
|
|
2
|
+
// persisted facts. Formatting only: no classification, arithmetic, or recomputation. Unavailable evidence
|
|
3
|
+
// is written as words ("unavailable", "not applicable"), never as zero.
|
|
4
|
+
import { STALE_RISK_CLASSIFICATION_EXPLANATIONS } from "./buildIncrementalChangeStalenessReport.js";
|
|
5
|
+
import { COMPARISON_PRESENTATION, PARTIAL_REFERENCE_CLASSIFICATION_EXPLANATIONS, TREATMENT_PRESENTATION, findReportComparison, findReportTreatment } from "./buildIncrementalChangeStalenessReportV2.js";
|
|
6
|
+
import { REINDEX_RECOMMENDATION_EXPLANATIONS } from "./buildWarmIndexReuseReport.js";
|
|
7
|
+
export function display(value, missing = "unavailable") {
|
|
8
|
+
return value === null || value === undefined ? missing : String(value);
|
|
9
|
+
}
|
|
10
|
+
export function treatmentBlock(scenario, treatmentId, label) {
|
|
11
|
+
const treatment = findReportTreatment(scenario, treatmentId);
|
|
12
|
+
if (!treatment) {
|
|
13
|
+
return { label, available: false, rows: [], fallbackStatement: null, refreshRows: [], forcedNeighborSample: null, filesRead: [], requiredFiles: [], observedFiles: [], missingFiles: [] };
|
|
14
|
+
}
|
|
15
|
+
const retrieval = treatment.retrieval;
|
|
16
|
+
const correctness = treatment.fakeAgent?.correctness;
|
|
17
|
+
const refresh = treatment.refreshExecution;
|
|
18
|
+
const rows = [
|
|
19
|
+
["Treatment status", treatment.status],
|
|
20
|
+
["Treatment intent", treatment.treatmentIntent],
|
|
21
|
+
["Failure reason", display(treatment.failureReason, "not applicable")],
|
|
22
|
+
["Active index phase", treatment.activeIndexPhase],
|
|
23
|
+
["Baseline freshness", treatment.baselineFreshness.status],
|
|
24
|
+
["Refreshed freshness", display(treatment.refreshedFreshness?.status, "not applicable")],
|
|
25
|
+
["Retrieval status", retrieval.status],
|
|
26
|
+
["Context characters", retrieval.status === "not-run" ? "unavailable" : String(retrieval.totalChars)],
|
|
27
|
+
["Estimated context tokens", retrieval.status === "not-run" ? "unavailable" : String(retrieval.totalEstimatedTokens)],
|
|
28
|
+
["Retrieval duration (ms)", retrieval.status === "not-run" ? "unavailable" : String(retrieval.durationMs)],
|
|
29
|
+
["Fake-agent status", display(treatment.fakeAgent?.status, "not run")],
|
|
30
|
+
["Correctness score", correctness && correctness.available && correctness.score !== null ? String(correctness.score) : "unavailable"],
|
|
31
|
+
["Required-file evidence", treatment.requiredFileEvidence.status],
|
|
32
|
+
["Required-file reason", display(treatment.requiredFileEvidence.reason, "not applicable")],
|
|
33
|
+
["Affected-neighborhood relationship", treatment.affectedNeighborhood.relationship],
|
|
34
|
+
["Reindex recommendation (observational)", treatment.affectedNeighborhood.reindexRecommendation]
|
|
35
|
+
];
|
|
36
|
+
const refreshRows = [
|
|
37
|
+
["Refresh kind", refresh.kind],
|
|
38
|
+
["Refresh realization", refresh.realization]
|
|
39
|
+
];
|
|
40
|
+
let fallbackStatement = null;
|
|
41
|
+
let forcedNeighborSample = null;
|
|
42
|
+
if (refresh.kind === "incremental") {
|
|
43
|
+
const upstream = refresh.incrementalRefresh;
|
|
44
|
+
refreshRows.push(["Requested scope", upstream.requestedScope], ["Applied scope", upstream.appliedScope], ["Selection status", upstream.selectionStatus], ["Fallback reason", display(upstream.fallbackReason, "not applicable")], ["Fresh extraction files", String(upstream.freshExtractionFileCount)], ["Reused files", String(upstream.reusedFileCount)], ["Forced-neighbor reanalysis files", String(upstream.forcedNeighborReanalysisFileCount)], ["Upstream seed files", display(upstream.seedFileCount, "not recorded")], ["Upstream seed symbols", display(upstream.seedSymbolCount, "not recorded")], ["Upstream affected nodes", display(upstream.affectedNodeCount, "not recorded")], ["Upstream affected edges", display(upstream.affectedEdgeCount, "not recorded")]);
|
|
45
|
+
forcedNeighborSample = [...upstream.forcedNeighborSample];
|
|
46
|
+
if (refresh.realization === "FALLBACK_FULL") {
|
|
47
|
+
fallbackStatement = `FALLBACK_FULL: requested ${upstream.requestedScope}, applied ${upstream.appliedScope}, selection status ${upstream.selectionStatus}, reason ${display(upstream.fallbackReason, "not recorded")}. The requested partial refresh was not applied; a full rebuild produced this index.`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
refreshRows.push(["Upstream incremental refresh evidence", "not applicable"]);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
label,
|
|
55
|
+
available: true,
|
|
56
|
+
rows,
|
|
57
|
+
fallbackStatement,
|
|
58
|
+
refreshRows,
|
|
59
|
+
forcedNeighborSample,
|
|
60
|
+
filesRead: [...retrieval.filesRead],
|
|
61
|
+
requiredFiles: [...treatment.requiredFileEvidence.requiredFiles],
|
|
62
|
+
observedFiles: [...treatment.requiredFileEvidence.observedFiles],
|
|
63
|
+
missingFiles: [...treatment.requiredFileEvidence.missingFiles]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function comparisonBlock(scenario, presentation) {
|
|
67
|
+
const comparison = findReportComparison(scenario, presentation.candidate);
|
|
68
|
+
if (!comparison)
|
|
69
|
+
return { label: presentation.label, available: false, rows: [], reasonCodes: [], explanation: "" };
|
|
70
|
+
if (comparison.kind === "stale-risk") {
|
|
71
|
+
const value = comparison.comparison;
|
|
72
|
+
return {
|
|
73
|
+
label: presentation.label,
|
|
74
|
+
available: true,
|
|
75
|
+
rows: [
|
|
76
|
+
["Correctness relation", value.correctnessRelation],
|
|
77
|
+
["Required-file evidence relation", value.requiredFileEvidenceRelation],
|
|
78
|
+
["Stale-risk classification", value.staleRiskClassification]
|
|
79
|
+
],
|
|
80
|
+
reasonCodes: [...value.reasonCodes],
|
|
81
|
+
explanation: STALE_RISK_CLASSIFICATION_EXPLANATIONS[value.staleRiskClassification]
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
label: presentation.label,
|
|
86
|
+
available: true,
|
|
87
|
+
rows: [
|
|
88
|
+
["Refresh realization", display(comparison.refreshRealization, "not recorded")],
|
|
89
|
+
["Candidate correctness relation", comparison.correctnessRelation],
|
|
90
|
+
["Candidate required-file relation", comparison.requiredFileEvidenceRelation],
|
|
91
|
+
["Reference classification", comparison.classification]
|
|
92
|
+
],
|
|
93
|
+
reasonCodes: [...comparison.reasonCodes],
|
|
94
|
+
explanation: PARTIAL_REFERENCE_CLASSIFICATION_EXPLANATIONS[comparison.classification]
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export const TREATMENT_PRESENTATION_ORDER = TREATMENT_PRESENTATION;
|
|
98
|
+
export const COMPARISON_PRESENTATION_ORDER = COMPARISON_PRESENTATION;
|
|
99
|
+
/**
|
|
100
|
+
* Baseline affected-neighborhood evidence is symmetric by construction of the execution; the report
|
|
101
|
+
* presents it once only when the persisted relationship and recommendation agree, and otherwise says so.
|
|
102
|
+
*/
|
|
103
|
+
export function affectedNeighborhoodSummary(scenario) {
|
|
104
|
+
const assessments = TREATMENT_PRESENTATION.map((entry) => findReportTreatment(scenario, entry.id)?.affectedNeighborhood ?? null).filter((assessment) => assessment !== null);
|
|
105
|
+
if (assessments.length === 0)
|
|
106
|
+
return { state: "none" };
|
|
107
|
+
const first = assessments[0];
|
|
108
|
+
if (assessments.some((a) => a.relationship !== first.relationship || a.reindexRecommendation !== first.reindexRecommendation))
|
|
109
|
+
return { state: "inconsistent" };
|
|
110
|
+
const number = (value) => display(value);
|
|
111
|
+
return {
|
|
112
|
+
state: "ok",
|
|
113
|
+
rows: [
|
|
114
|
+
["Relationship", first.relationship],
|
|
115
|
+
["Reindex recommendation (observational)", first.reindexRecommendation],
|
|
116
|
+
["Changed indexed files", number(first.changedFileCount)],
|
|
117
|
+
["Changed baseline symbols", number(first.changedSymbolCount)],
|
|
118
|
+
["Affected graph nodes", number(first.affectedNodeCount)],
|
|
119
|
+
["Affected graph edges", number(first.affectedEdgeCount)],
|
|
120
|
+
["Task-overlap nodes", number(first.taskOverlapCount)],
|
|
121
|
+
["Task-overlap percent", number(first.taskOverlapPercent)]
|
|
122
|
+
],
|
|
123
|
+
explanation: REINDEX_RECOMMENDATION_EXPLANATIONS[first.reindexRecommendation]
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export const EVIDENCE_FAMILY_NOTE = "Lab affected-neighborhood evidence describes the baseline graph neighborhood implicated by the controlled change. Upstream refresh evidence describes what my-dev-kit actually refreshed. They are different evidence families.";
|
|
127
|
+
export const SCENARIO_SUMMARY_ROWS = (summary) => [
|
|
128
|
+
["Scenarios", String(summary.scenarioCount)],
|
|
129
|
+
["Ready", String(summary.readyScenarioCount)],
|
|
130
|
+
["Failed", String(summary.failedScenarioCount)],
|
|
131
|
+
["No refresh: observed stale regression", String(summary.staleObservedRegressionCount)],
|
|
132
|
+
["No refresh: no observed stale regression", String(summary.staleNoObservedRegressionCount)],
|
|
133
|
+
["No refresh: inconclusive", String(summary.staleInconclusiveCount)],
|
|
134
|
+
["Changed-files: applied partial", String(summary.changedFilesAppliedPartialCount)],
|
|
135
|
+
["Changed-files: fallback to full", String(summary.changedFilesFallbackFullCount)],
|
|
136
|
+
["Affected-neighborhood: applied partial", String(summary.affectedNeighborhoodAppliedPartialCount)],
|
|
137
|
+
["Affected-neighborhood: fallback to full", String(summary.affectedNeighborhoodFallbackFullCount)],
|
|
138
|
+
["Changed-files: observed regression relative to full", String(summary.changedFilesObservedRegressionRelativeToFullCount)],
|
|
139
|
+
["Changed-files: no observed regression relative to full", String(summary.changedFilesNoObservedRegressionRelativeToFullCount)],
|
|
140
|
+
["Changed-files: inconclusive", String(summary.changedFilesInconclusiveCount)],
|
|
141
|
+
["Changed-files: not comparable as partial refresh", String(summary.changedFilesNotComparableCount)],
|
|
142
|
+
["Affected-neighborhood: observed regression relative to full", String(summary.affectedNeighborhoodObservedRegressionRelativeToFullCount)],
|
|
143
|
+
["Affected-neighborhood: no observed regression relative to full", String(summary.affectedNeighborhoodNoObservedRegressionRelativeToFullCount)],
|
|
144
|
+
["Affected-neighborhood: inconclusive", String(summary.affectedNeighborhoodInconclusiveCount)],
|
|
145
|
+
["Affected-neighborhood: not comparable as partial refresh", String(summary.affectedNeighborhoodNotComparableCount)]
|
|
146
|
+
];
|
|
@@ -15,5 +15,8 @@ export { renderContextIntegrityJsonReport } from "./renderContextIntegrityJsonRe
|
|
|
15
15
|
export { renderContextIntegrityText } from "./renderContextIntegrityText.js";
|
|
16
16
|
export { renderContextIntegrityHtml } from "./renderContextIntegrityHtml.js";
|
|
17
17
|
export * from "./incrementalChangeStalenessReportModel.js";
|
|
18
|
+
export * from "./incrementalChangeStalenessReportModelV2.js";
|
|
19
|
+
export { buildIncrementalChangeStalenessPluginReport } from "./buildIncrementalChangeStalenessPluginReport.js";
|
|
20
|
+
export { buildIncrementalChangeStalenessReportV2FromArtifact, findReportTreatment, findReportComparison, LIMITATIONS_V2 } from "./buildIncrementalChangeStalenessReportV2.js";
|
|
18
21
|
export { buildIncrementalChangeStalenessReport, STALE_RISK_CLASSIFICATION_EXPLANATIONS } from "./buildIncrementalChangeStalenessReport.js";
|
|
19
22
|
export { renderIncrementalChangeStalenessHtml } from "./renderIncrementalChangeStalenessHtml.js";
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
// calculation beyond formatting an already-persisted number.
|
|
4
4
|
import { STALE_RISK_CLASSIFICATION_EXPLANATIONS } from "./buildIncrementalChangeStalenessReport.js";
|
|
5
5
|
import { REINDEX_RECOMMENDATION_EXPLANATIONS } from "./buildWarmIndexReuseReport.js";
|
|
6
|
+
import { isV2ReportSection } from "./buildIncrementalChangeStalenessReportV2.js";
|
|
7
|
+
import { renderIncrementalChangeStalenessHtmlV2 } from "./renderIncrementalChangeStalenessHtmlV2.js";
|
|
6
8
|
const MAX_DISPLAY_ITEMS = 20;
|
|
7
9
|
export function renderIncrementalChangeStalenessHtml(section) {
|
|
10
|
+
if (section !== null && isV2ReportSection(section))
|
|
11
|
+
return renderIncrementalChangeStalenessHtmlV2(section);
|
|
8
12
|
if (section === null) {
|
|
9
13
|
return `<section>
|
|
10
14
|
<h2>Incremental-Change And Staleness Evidence</h2>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// v0.6.3 -- V2 HTML section for the incremental-change-staleness plugin report. Renders the same persisted
|
|
2
|
+
// facts as the text renderer through the shared row builders; every persisted string is HTML-escaped.
|
|
3
|
+
import { COMPARISON_PRESENTATION_ORDER, EVIDENCE_FAMILY_NOTE, SCENARIO_SUMMARY_ROWS, TREATMENT_PRESENTATION_ORDER, affectedNeighborhoodSummary, comparisonBlock, treatmentBlock } from "./incrementalChangeStalenessReportRowsV2.js";
|
|
4
|
+
const MAX_DISPLAY_ITEMS = 20;
|
|
5
|
+
export function renderIncrementalChangeStalenessHtmlV2(section) {
|
|
6
|
+
return `<section>
|
|
7
|
+
<h2>Incremental-Change And Staleness Evidence</h2>
|
|
8
|
+
<p class="muted">Scoped experimental evidence: results are limited to the executed scenarios below. Full refresh is a comparison reference, not a declared best treatment.</p>
|
|
9
|
+
${table(["Field", "Value"], SCENARIO_SUMMARY_ROWS(section.summary))}
|
|
10
|
+
${section.scenarios.map(renderScenario).join("\n")}
|
|
11
|
+
<h3>Limitations</h3>
|
|
12
|
+
${list(section.limitations)}
|
|
13
|
+
</section>`;
|
|
14
|
+
}
|
|
15
|
+
function renderScenario(scenario) {
|
|
16
|
+
const lifecycle = scenario.lifecycle;
|
|
17
|
+
const affected = affectedNeighborhoodSummary(scenario);
|
|
18
|
+
return `<h3>Scenario: ${escapeHtml(scenario.scenarioId)}</h3>
|
|
19
|
+
<h4>Scenario Definition</h4>
|
|
20
|
+
${table(["Field", "Value"], [
|
|
21
|
+
["Scenario ID", scenario.scenarioId],
|
|
22
|
+
["Category", scenario.category],
|
|
23
|
+
["Benchmark project", scenario.benchmarkProjectId],
|
|
24
|
+
["Base case", scenario.baseCaseId],
|
|
25
|
+
["Answer policy", scenario.answerPolicy],
|
|
26
|
+
["Query", scenario.query ?? "unavailable"],
|
|
27
|
+
["Status", scenario.status],
|
|
28
|
+
["Failure reason", scenario.failureReason ?? "not applicable"]
|
|
29
|
+
])}
|
|
30
|
+
<p>Expected files:</p>
|
|
31
|
+
${boundedList(scenario.expectedFiles)}
|
|
32
|
+
<p>Expected symbols:</p>
|
|
33
|
+
${boundedList(scenario.expectedSymbols)}
|
|
34
|
+
<h4>Lifecycle Integrity</h4>
|
|
35
|
+
${lifecycle
|
|
36
|
+
? `${table(["Field", "Value"], [
|
|
37
|
+
["my-dev-kit version", lifecycle.myDevKitVersion ?? "unavailable"],
|
|
38
|
+
["Index invocations (total)", String(lifecycle.totalIndexInvocationCount)],
|
|
39
|
+
["Freshness assessments (total)", String(lifecycle.totalFreshnessAssessmentCount)],
|
|
40
|
+
...Object.entries(lifecycle.preMutationEquivalence).map(([id, result]) => [`Pre-mutation equivalence (${id} vs stale-index reference)`, result]),
|
|
41
|
+
...Object.entries(lifecycle.postMutationEquivalence).map(([id, result]) => [`Post-mutation equivalence (${id} vs stale-index reference)`, result])
|
|
42
|
+
])}<p>Controlled file paths:</p>${boundedList(lifecycle.controlledFilePaths)}<p>Source roots:</p>${boundedList(lifecycle.sourceRoots)}`
|
|
43
|
+
: "<p>Not available for this scenario.</p>"}
|
|
44
|
+
<h4>Affected-Neighborhood Evidence (Changed Baseline Neighborhood Relative To The Task)</h4>
|
|
45
|
+
${affected.state === "none"
|
|
46
|
+
? "<p>Not available.</p>"
|
|
47
|
+
: affected.state === "inconsistent"
|
|
48
|
+
? "<p>inconsistent persisted evidence</p>"
|
|
49
|
+
: `${table(["Field", "Value"], affected.rows)}<p>${escapeHtml(affected.explanation)}</p>`}
|
|
50
|
+
<p class="muted">${escapeHtml(EVIDENCE_FAMILY_NOTE)}</p>
|
|
51
|
+
${scenario.treatments.length === 0
|
|
52
|
+
? "<h4>Treatments</h4><p>Not produced: the scenario failed before any treatment evidence existed.</p>"
|
|
53
|
+
: TREATMENT_PRESENTATION_ORDER.map((entry) => renderTreatment(scenario, entry)).join("\n")}
|
|
54
|
+
<h4>Reference Comparisons (Against The Full-Refresh Reference)</h4>
|
|
55
|
+
${COMPARISON_PRESENTATION_ORDER.map((entry) => renderComparison(scenario, entry)).join("\n")}`;
|
|
56
|
+
}
|
|
57
|
+
function renderTreatment(scenario, entry) {
|
|
58
|
+
const block = treatmentBlock(scenario, entry.id, entry.label);
|
|
59
|
+
const title = `${block.label} Treatment (${entry.id})`;
|
|
60
|
+
if (!block.available)
|
|
61
|
+
return `<h4>${escapeHtml(title)}</h4><p>Not available.</p>`;
|
|
62
|
+
return `<h4>${escapeHtml(title)}</h4>
|
|
63
|
+
${table(["Field", "Value"], block.rows)}
|
|
64
|
+
<p><strong>Refresh Execution</strong> (upstream evidence of what my-dev-kit actually did)</p>
|
|
65
|
+
${table(["Field", "Value"], block.refreshRows)}
|
|
66
|
+
${block.fallbackStatement ? `<p class="fallback"><strong>${escapeHtml(block.fallbackStatement)}</strong></p>` : ""}
|
|
67
|
+
${block.forcedNeighborSample ? `<p>Forced-neighbor sample:</p>${boundedList(block.forcedNeighborSample)}` : ""}
|
|
68
|
+
<p>Files read:</p>
|
|
69
|
+
${boundedList(block.filesRead)}
|
|
70
|
+
<p>Required files:</p>
|
|
71
|
+
${boundedList(block.requiredFiles)}
|
|
72
|
+
<p>Observed files:</p>
|
|
73
|
+
${boundedList(block.observedFiles)}
|
|
74
|
+
<p>Missing files:</p>
|
|
75
|
+
${boundedList(block.missingFiles)}`;
|
|
76
|
+
}
|
|
77
|
+
function renderComparison(scenario, entry) {
|
|
78
|
+
const block = comparisonBlock(scenario, entry);
|
|
79
|
+
if (!block.available)
|
|
80
|
+
return `<h5>${escapeHtml(block.label)}</h5><p>Not available.</p>`;
|
|
81
|
+
return `<h5>${escapeHtml(block.label)}</h5>
|
|
82
|
+
${table(["Field", "Value"], block.rows)}
|
|
83
|
+
<p>Reason codes:</p>
|
|
84
|
+
${list(block.reasonCodes)}
|
|
85
|
+
<p>${escapeHtml(block.explanation)}</p>`;
|
|
86
|
+
}
|
|
87
|
+
function boundedList(items) {
|
|
88
|
+
if (items.length === 0)
|
|
89
|
+
return "<p>none</p>";
|
|
90
|
+
const shown = items.slice(0, MAX_DISPLAY_ITEMS);
|
|
91
|
+
const omitted = items.length - shown.length;
|
|
92
|
+
return `${list(shown)}${omitted > 0 ? `<p class="muted">... ${omitted} more</p>` : ""}`;
|
|
93
|
+
}
|
|
94
|
+
function list(items) {
|
|
95
|
+
if (items.length === 0)
|
|
96
|
+
return "<ul><li>none</li></ul>";
|
|
97
|
+
return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`;
|
|
98
|
+
}
|
|
99
|
+
function table(headers, rows) {
|
|
100
|
+
return `<table><thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead><tbody>${rows
|
|
101
|
+
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`)
|
|
102
|
+
.join("")}</tbody></table>`;
|
|
103
|
+
}
|
|
104
|
+
function escapeHtml(value) {
|
|
105
|
+
return String(value ?? "")
|
|
106
|
+
.replaceAll("&", "&")
|
|
107
|
+
.replaceAll("<", "<")
|
|
108
|
+
.replaceAll(">", ">")
|
|
109
|
+
.replaceAll('"', """)
|
|
110
|
+
.replaceAll("'", "'");
|
|
111
|
+
}
|