@dailephd/my-dev-kit-lab 0.6.1 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +9 -7
  3. package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
  4. package/dist/src/cli/help.js +16 -9
  5. package/dist/src/commands/runExperimentDescribeCommand.js +5 -2
  6. package/dist/src/commands/runExperimentRunCommand.js +4 -7
  7. package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
  8. package/dist/src/evaluation/benchmarkMetadata.js +5 -1
  9. package/dist/src/evaluation/parseAgentAnswer.js +172 -24
  10. package/dist/src/evaluation/runMyDevKitRetrieval.js +120 -7
  11. package/dist/src/experiments/defaultRegistry.js +2 -0
  12. package/dist/src/experiments/plugins/incrementalChangeStaleness/comparison.js +101 -0
  13. package/dist/src/experiments/plugins/incrementalChangeStaleness/comparisonV2.js +49 -0
  14. package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +67 -0
  15. package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +125 -0
  16. package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +430 -0
  17. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +160 -0
  18. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifactV2.js +289 -0
  19. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionV2.js +195 -0
  20. package/dist/src/experiments/plugins/incrementalChangeStaleness/index.js +13 -0
  21. package/dist/src/experiments/plugins/incrementalChangeStaleness/indexClone.js +76 -0
  22. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +308 -0
  23. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +154 -0
  24. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicyV2.js +78 -0
  25. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycleV2.js +298 -0
  26. package/dist/src/experiments/plugins/incrementalChangeStaleness/mutationExecution.js +144 -0
  27. package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +475 -0
  28. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioCatalog.js +42 -0
  29. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioMutation.js +116 -0
  30. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioSelection.js +79 -0
  31. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioTypes.js +37 -0
  32. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioValidation.js +168 -0
  33. package/dist/src/experiments/plugins/incrementalChangeStaleness/sourceState.js +145 -0
  34. package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSession.js +6 -0
  35. package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSessionV2.js +5 -0
  36. package/dist/src/experiments/plugins/index.js +1 -0
  37. package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +17 -20
  38. package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +3 -1
  39. package/dist/src/mobile/android/advancedSecurity/candidateEvidence.js +9 -2
  40. package/dist/src/mobile/android/advancedSecurity/redaction.js +19 -10
  41. package/dist/src/mobile/android/advancedSecurity/secretCandidates/analyzeSecretCandidates.js +12 -2
  42. package/dist/src/mobile/android/advancedSecurity/signingConfiguration/analyzeSigningConfiguration.js +1 -1
  43. package/dist/src/mobile/android/advancedSecurity/signingConfiguration/extractSigningConfigurations.js +7 -7
  44. package/dist/src/report/experiments/buildIncrementalChangeStalenessPluginReport.js +33 -0
  45. package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
  46. package/dist/src/report/experiments/buildIncrementalChangeStalenessReportV2.js +80 -0
  47. package/dist/src/report/experiments/buildPluginExperimentReport.js +3 -0
  48. package/dist/src/report/experiments/incrementalChangeStalenessReportModel.js +4 -0
  49. package/dist/src/report/experiments/incrementalChangeStalenessReportModelV2.js +3 -0
  50. package/dist/src/report/experiments/incrementalChangeStalenessReportRowsV2.js +146 -0
  51. package/dist/src/report/experiments/index.js +6 -0
  52. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +243 -0
  53. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtmlV2.js +111 -0
  54. package/dist/src/report/experiments/renderIncrementalChangeStalenessTextV2.js +96 -0
  55. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
  56. package/dist/src/report/experiments/renderPluginExperimentReportText.js +144 -0
  57. package/dist/src/securityValidation/staticScans/codeql.js +8 -8
  58. package/docs/METRICS.md +67 -1
  59. package/examples/tutorial-browser/prepare.mjs +13 -13
  60. package/examples/tutorial-browser/server.mjs +13 -13
  61. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ import path from "node:path";
2
+ import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
3
+ import { readEvaluationCases } from "../../../evaluation/readEvaluationCases.js";
4
+ import { normalizeRootForComparison } from "./lifecyclePolicy.js";
5
+ import { BENCHMARK_PROJECT_PROFILES_PATH, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH, WARM_INDEX_BENCHMARK_CASES_PATH, readIncrementalChangeStalenessScenarioCatalog } from "./scenarioCatalog.js";
6
+ /**
7
+ * Policy: selects scenarios from the validated catalog. No selection means
8
+ * every catalog scenario. An explicit selection must be non-empty, contain no
9
+ * duplicates, and name only catalog scenarios. The result always follows
10
+ * catalog order, never request order, so run ordering is deterministic.
11
+ */
12
+ export function selectIncrementalChangeStalenessScenarios(catalog, scenarioIds) {
13
+ if (scenarioIds === undefined) {
14
+ return [...catalog.scenarios];
15
+ }
16
+ if (scenarioIds.length === 0) {
17
+ throw new Error("Scenario selection must name at least one scenario when provided.");
18
+ }
19
+ const duplicates = scenarioIds.filter((id, index) => scenarioIds.indexOf(id) !== index);
20
+ if (duplicates.length > 0) {
21
+ throw new Error(`Scenario selection contains duplicate scenario id(s): ${[...new Set(duplicates)].join(", ")}.`);
22
+ }
23
+ const known = new Set(catalog.scenarios.map((scenario) => scenario.id));
24
+ const unknown = scenarioIds.filter((id) => !known.has(id));
25
+ if (unknown.length > 0) {
26
+ throw new Error(`Unknown incremental-change-staleness scenario id(s): ${unknown.join(", ")}. Known: ${[...known].join(", ")}.`);
27
+ }
28
+ const requested = new Set(scenarioIds);
29
+ return catalog.scenarios.filter((scenario) => requested.has(scenario.id));
30
+ }
31
+ /**
32
+ * Policy: resolves a scenario's canonical base case identity from the existing
33
+ * benchmark case and project-profile contracts. Source roots always come from
34
+ * the base case; the canonical project root always comes from the project
35
+ * profile, and the two must agree.
36
+ */
37
+ export function resolveIncrementalChangeStalenessBaseCase(scenario, cases, profiles, repoRoot) {
38
+ const baseCase = cases.find((candidate) => candidate.id === scenario.baseCaseId);
39
+ if (!baseCase) {
40
+ throw new Error(`Scenario ${scenario.id}: base case ${scenario.baseCaseId} was not found.`);
41
+ }
42
+ const profile = profiles.find((candidate) => candidate.projectId === scenario.benchmarkProjectId);
43
+ if (!profile) {
44
+ throw new Error(`Scenario ${scenario.id}: benchmark project ${scenario.benchmarkProjectId} was not found.`);
45
+ }
46
+ if (baseCase.benchmarkProject !== scenario.benchmarkProjectId) {
47
+ throw new Error(`Scenario ${scenario.id}: base case ${baseCase.id} belongs to ${baseCase.benchmarkProject}, not ${scenario.benchmarkProjectId}.`);
48
+ }
49
+ const canonicalProjectRoot = path.resolve(repoRoot, profile.rootPath);
50
+ if (normalizeRootForComparison(canonicalProjectRoot) !== normalizeRootForComparison(baseCase.absoluteTargetRoot)) {
51
+ throw new Error(`Scenario ${scenario.id}: base case target root ${baseCase.absoluteTargetRoot} does not match project profile root ${canonicalProjectRoot}.`);
52
+ }
53
+ if (!Array.isArray(baseCase.sourceRoots) || baseCase.sourceRoots.length === 0) {
54
+ throw new Error(`Scenario ${scenario.id}: base case ${baseCase.id} declares no source roots.`);
55
+ }
56
+ return Object.freeze({
57
+ caseId: baseCase.id,
58
+ benchmarkProjectId: scenario.benchmarkProjectId,
59
+ canonicalProjectRootRelative: profile.rootPath,
60
+ canonicalProjectRoot,
61
+ sourceRoots: Object.freeze([...baseCase.sourceRoots])
62
+ });
63
+ }
64
+ /**
65
+ * Loads the canonical production scenario catalog (the only mutation
66
+ * authority) and its benchmark case/profile dependencies, then resolves the
67
+ * selected scenarios with their base-case identities in catalog order.
68
+ * Read-only.
69
+ */
70
+ export async function resolveIncrementalChangeStalenessScenarios(options) {
71
+ const { repoRoot } = options;
72
+ const profiles = await readBenchmarkProjectProfiles(path.resolve(repoRoot, BENCHMARK_PROJECT_PROFILES_PATH), repoRoot);
73
+ const cases = await readEvaluationCases(path.resolve(repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), repoRoot);
74
+ const catalog = await readIncrementalChangeStalenessScenarioCatalog(path.resolve(repoRoot, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH), profiles, cases, repoRoot, { requireFrozenProductionScenarios: true });
75
+ return selectIncrementalChangeStalenessScenarios(catalog, options.scenarioIds).map((scenario) => ({
76
+ scenario,
77
+ baseCase: resolveIncrementalChangeStalenessBaseCase(scenario, cases, profiles, repoRoot)
78
+ }));
79
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * v0.6.2 incremental-change-staleness scenario catalog schema version.
3
+ *
4
+ * This is a separate, versioned contract from the warm-index benchmark case
5
+ * contract (`benchmarks/contracts/warm-index-benchmark-cases.json`), which
6
+ * remains unchanged. Scenario records here reference existing benchmark
7
+ * project/case ids rather than duplicating canonical project/target metadata.
8
+ */
9
+ export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_SCHEMA_VERSION = "1.0.0";
10
+ /**
11
+ * The experimental dimension a scenario deliberately changes. Categories are
12
+ * not required to be structurally exclusive (for example, an exported
13
+ * function body is still implementation code); the selected category
14
+ * describes the intended experimental contrast, not an exhaustive structural
15
+ * classification.
16
+ */
17
+ export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATEGORIES = [
18
+ "unrelated-file-change",
19
+ "local-implementation-change",
20
+ "exported-symbol-change",
21
+ "public-api-change",
22
+ "import-graph-change",
23
+ "test-only-change"
24
+ ];
25
+ /**
26
+ * Answer-key policy for a scenario's follow-up query:
27
+ * - `inherit`: reuse the referenced base case's existing query/answer key
28
+ * because the controlled change does not alter that task's semantic truth.
29
+ * - `scenario`: use a scenario-specific post-mutation query and answer key,
30
+ * expressed with the existing benchmark answer-key structure.
31
+ *
32
+ * Exactly these two policies are supported; no other correctness vocabulary
33
+ * is introduced.
34
+ */
35
+ export const INCREMENTAL_CHANGE_STALENESS_ANSWER_POLICIES = ["inherit", "scenario"];
36
+ /** The six planner-frozen production scenario ids, in their planned order. */
37
+ export const FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS = ["U1", "L2", "E1", "P1", "I1", "T1"];
@@ -0,0 +1,168 @@
1
+ import path from "node:path";
2
+ import { validateAnswerKey } from "../../../evaluation/benchmarkMetadata.js";
3
+ import { validateMutationFile } from "./scenarioMutation.js";
4
+ import { FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS, INCREMENTAL_CHANGE_STALENESS_ANSWER_POLICIES, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATEGORIES, INCREMENTAL_CHANGE_STALENESS_SCENARIO_SCHEMA_VERSION } from "./scenarioTypes.js";
5
+ /** Category each frozen production scenario id is required to declare. */
6
+ export const FROZEN_SCENARIO_CATEGORY_BY_ID = {
7
+ U1: "unrelated-file-change",
8
+ L2: "local-implementation-change",
9
+ E1: "exported-symbol-change",
10
+ P1: "public-api-change",
11
+ I1: "import-graph-change",
12
+ T1: "test-only-change"
13
+ };
14
+ /** Substrings that must never appear in scenario notes/query text (Section 7/9/22.I). */
15
+ const FORBIDDEN_TEXT_PATTERNS = [/partial-refresh/i, /changed-files-refresh/i, /affected-neighborhood-refresh/i, /graph-diff/i];
16
+ /**
17
+ * Strictly validates a parsed incremental-change-staleness scenario catalog.
18
+ * Performs schema/vocabulary checks, benchmark project/case reference checks,
19
+ * answer-policy completeness checks, forbidden-content checks, and full
20
+ * in-memory mutation validation (pre-hash, ordered exact-preimage
21
+ * replacement, post-hash) against the real canonical benchmark files. Never
22
+ * writes to disk.
23
+ */
24
+ export async function validateIncrementalChangeStalenessCatalog(catalog, profiles, cases, repoRoot, options = {}) {
25
+ const errors = [];
26
+ if (!catalog || typeof catalog !== "object") {
27
+ return ["Scenario catalog must be an object."];
28
+ }
29
+ const candidate = catalog;
30
+ if (candidate.schemaVersion !== INCREMENTAL_CHANGE_STALENESS_SCENARIO_SCHEMA_VERSION) {
31
+ errors.push(`schemaVersion must be ${INCREMENTAL_CHANGE_STALENESS_SCENARIO_SCHEMA_VERSION}; received ${JSON.stringify(candidate.schemaVersion)}.`);
32
+ }
33
+ if (!Array.isArray(candidate.scenarios)) {
34
+ errors.push("scenarios must be an array.");
35
+ return errors;
36
+ }
37
+ const profilesById = new Map(profiles.map((profile) => [profile.projectId, profile]));
38
+ const casesById = new Map(cases.map((benchmarkCase) => [benchmarkCase.id, benchmarkCase]));
39
+ const seenIds = new Set();
40
+ for (const [index, scenario] of candidate.scenarios.entries()) {
41
+ errors.push(...(await validateOneScenario(scenario, index, profilesById, casesById, repoRoot, seenIds)));
42
+ }
43
+ if (options.requireFrozenProductionScenarios === true) {
44
+ const actualIds = candidate.scenarios
45
+ .map((scenario) => (scenario && typeof scenario === "object" ? scenario.id : undefined))
46
+ .filter((id) => typeof id === "string");
47
+ const expected = FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS;
48
+ const actualSet = new Set(actualIds);
49
+ const missing = expected.filter((id) => !actualSet.has(id));
50
+ const extra = actualIds.filter((id) => !expected.includes(id));
51
+ if (missing.length > 0) {
52
+ errors.push(`Production catalog is missing required frozen scenario(s): ${missing.join(", ")}.`);
53
+ }
54
+ if (extra.length > 0) {
55
+ errors.push(`Production catalog contains unexpected scenario id(s) beyond the six frozen scenarios: ${extra.join(", ")}.`);
56
+ }
57
+ if (actualIds.length !== expected.length || missing.length > 0 || extra.length > 0) {
58
+ errors.push(`Production catalog must contain exactly the six frozen scenarios ${expected.join(", ")}; received ${actualIds.join(", ") || "<none>"}.`);
59
+ }
60
+ }
61
+ return errors;
62
+ }
63
+ async function validateOneScenario(scenario, index, profilesById, casesById, repoRoot, seenIds) {
64
+ const errors = [];
65
+ if (!scenario || typeof scenario !== "object") {
66
+ return [`scenario at index ${index}: must be an object.`];
67
+ }
68
+ const candidate = scenario;
69
+ const id = typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : undefined;
70
+ const label = id === undefined ? `scenario at index ${index}` : `scenario ${id}`;
71
+ if (id === undefined) {
72
+ errors.push(`${label}: id must be a nonempty string.`);
73
+ }
74
+ else if (seenIds.has(id)) {
75
+ errors.push(`${label}: duplicate scenario id.`);
76
+ }
77
+ else {
78
+ seenIds.add(id);
79
+ }
80
+ if (typeof candidate.category !== "string" || !INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATEGORIES.includes(candidate.category)) {
81
+ errors.push(`${label}: category must be one of ${INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATEGORIES.join(", ")}; received ${JSON.stringify(candidate.category)}.`);
82
+ }
83
+ else if (id !== undefined && id in FROZEN_SCENARIO_CATEGORY_BY_ID) {
84
+ const expectedCategory = FROZEN_SCENARIO_CATEGORY_BY_ID[id];
85
+ if (candidate.category !== expectedCategory) {
86
+ errors.push(`${label}: category must be ${expectedCategory} for frozen scenario ${id}; received ${candidate.category}.`);
87
+ }
88
+ }
89
+ if (typeof candidate.answerPolicy !== "string" || !INCREMENTAL_CHANGE_STALENESS_ANSWER_POLICIES.includes(candidate.answerPolicy)) {
90
+ errors.push(`${label}: answerPolicy must be one of ${INCREMENTAL_CHANGE_STALENESS_ANSWER_POLICIES.join(", ")}; received ${JSON.stringify(candidate.answerPolicy)}.`);
91
+ }
92
+ const projectId = typeof candidate.benchmarkProjectId === "string" && candidate.benchmarkProjectId.length > 0 ? candidate.benchmarkProjectId : undefined;
93
+ if (projectId === undefined) {
94
+ errors.push(`${label}: benchmarkProjectId must be a nonempty string.`);
95
+ }
96
+ const profile = projectId === undefined ? undefined : profilesById.get(projectId);
97
+ if (projectId !== undefined && profile === undefined) {
98
+ errors.push(`${label}: unknown benchmarkProjectId ${projectId}.`);
99
+ }
100
+ const caseId = typeof candidate.baseCaseId === "string" && candidate.baseCaseId.length > 0 ? candidate.baseCaseId : undefined;
101
+ if (caseId === undefined) {
102
+ errors.push(`${label}: baseCaseId must be a nonempty string.`);
103
+ }
104
+ const baseCase = caseId === undefined ? undefined : casesById.get(caseId);
105
+ if (caseId !== undefined && baseCase === undefined) {
106
+ errors.push(`${label}: unknown baseCaseId ${caseId}.`);
107
+ }
108
+ if (baseCase !== undefined && projectId !== undefined && baseCase.benchmarkProject !== projectId) {
109
+ errors.push(`${label}: baseCaseId ${caseId} belongs to benchmark project ${baseCase.benchmarkProject}, not the referenced benchmarkProjectId ${projectId}.`);
110
+ }
111
+ // Answer-policy completeness (Section 22.H). Reuses the existing answer-key structure/validator.
112
+ if (candidate.answerPolicy === "inherit") {
113
+ if (baseCase !== undefined) {
114
+ if (typeof baseCase.query !== "string" || baseCase.query.length === 0) {
115
+ errors.push(`${label}: inherit policy requires the base case to have a nonempty query.`);
116
+ }
117
+ if (baseCase.answerKey === undefined) {
118
+ errors.push(`${label}: inherit policy requires the base case to have an answerKey.`);
119
+ }
120
+ else {
121
+ errors.push(...validateAnswerKey(baseCase.answerKey, `${label} (inherited answerKey)`).map((message) => message));
122
+ }
123
+ }
124
+ if (candidate.scenarioQuery !== undefined || candidate.scenarioAnswerKey !== undefined) {
125
+ errors.push(`${label}: inherit policy must not declare scenarioQuery or scenarioAnswerKey.`);
126
+ }
127
+ }
128
+ else if (candidate.answerPolicy === "scenario") {
129
+ if (typeof candidate.scenarioQuery !== "string" || candidate.scenarioQuery.trim().length === 0) {
130
+ errors.push(`${label}: scenario policy requires a nonempty scenarioQuery.`);
131
+ }
132
+ if (candidate.scenarioAnswerKey === undefined) {
133
+ errors.push(`${label}: scenario policy requires a scenarioAnswerKey.`);
134
+ }
135
+ else {
136
+ errors.push(...validateAnswerKey(candidate.scenarioAnswerKey, `${label}.scenarioAnswerKey`));
137
+ }
138
+ }
139
+ if (candidate.expectedRelationship !== undefined && !["related", "unrelated", "unknown"].includes(candidate.expectedRelationship)) {
140
+ errors.push(`${label}: expectedRelationship must be one of related, unrelated, unknown; received ${JSON.stringify(candidate.expectedRelationship)}.`);
141
+ }
142
+ for (const text of [candidate.notes, candidate.scenarioQuery, candidate.id, candidate.category]) {
143
+ if (typeof text === "string") {
144
+ for (const pattern of FORBIDDEN_TEXT_PATTERNS) {
145
+ if (pattern.test(text)) {
146
+ errors.push(`${label}: forbidden v0.6.3+/graph-diff reference found in scenario text: matched ${pattern}.`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ if (!candidate.mutation || typeof candidate.mutation !== "object" || !Array.isArray(candidate.mutation.files) || candidate.mutation.files.length === 0) {
152
+ errors.push(`${label}: mutation.files must be a nonempty array.`);
153
+ return errors;
154
+ }
155
+ if (profile !== undefined && baseCase !== undefined) {
156
+ const projectAbsoluteRoot = path.resolve(repoRoot, profile.rootPath);
157
+ const caseSourceRoots = Array.isArray(baseCase.sourceRoots) ? baseCase.sourceRoots : [];
158
+ for (const [fileIndex, file] of candidate.mutation.files.entries()) {
159
+ const fileLabel = `${label}.mutation.files[${fileIndex}]`;
160
+ const result = await validateMutationFile(fileLabel, file, projectAbsoluteRoot, caseSourceRoots);
161
+ errors.push(...result.errors);
162
+ }
163
+ }
164
+ else {
165
+ errors.push(`${label}: mutation files cannot be validated because the benchmark project/case reference is invalid.`);
166
+ }
167
+ return errors;
168
+ }
@@ -0,0 +1,145 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
5
+ import { sha256Hex } from "./scenarioMutation.js";
6
+ /**
7
+ * v0.6.2 Batch 2 controlled-source-state schema version. This is a
8
+ * copy-equivalence guard scoped to a benchmark case's configured indexed
9
+ * source roots; it is NOT the real `IndexSnapshotV1` captured from an actual
10
+ * my-dev-kit index build (that remains Batch 3's responsibility).
11
+ */
12
+ export const INCREMENTAL_CHANGE_STALENESS_SOURCE_STATE_SCHEMA_VERSION = "1.0.0";
13
+ function normalizeSourceRoot(root) {
14
+ return root.replace(/\\/g, "/").replace(/\/+$/, "");
15
+ }
16
+ /**
17
+ * Recursively enumerates regular files (symbolic links are skipped, never
18
+ * followed) underneath one project-relative source root, hashing each with
19
+ * SHA-256. Deterministic: results are always returned to the caller sorted.
20
+ */
21
+ async function collectSourceRootFiles(projectRoot, sourceRoot) {
22
+ const absoluteRoot = resolveWithinRoot(projectRoot, sourceRoot);
23
+ const results = [];
24
+ async function walk(currentAbsoluteDir) {
25
+ const entries = await readdir(currentAbsoluteDir, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ if (entry.isSymbolicLink()) {
28
+ // Never follow symlinks when building controlled source-state evidence.
29
+ continue;
30
+ }
31
+ const entryAbsolutePath = path.join(currentAbsoluteDir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ await walk(entryAbsolutePath);
34
+ }
35
+ else if (entry.isFile()) {
36
+ const buffer = await readFile(entryAbsolutePath);
37
+ const relativePath = path.relative(projectRoot, entryAbsolutePath).split(path.sep).join("/");
38
+ results.push({ relativePath, sha256: sha256Hex(buffer) });
39
+ }
40
+ }
41
+ }
42
+ await walk(absoluteRoot);
43
+ return results;
44
+ }
45
+ function computeDigest(files) {
46
+ const serialized = files.map((file) => `${file.relativePath}\t${file.sha256}\n`).join("");
47
+ return createHash("sha256").update(Buffer.from(serialized, "utf8")).digest("hex");
48
+ }
49
+ /**
50
+ * Captures bounded, deterministic controlled source-state evidence for one
51
+ * disposable treatment copy, scoped to the base case's own configured
52
+ * `sourceRoots` (never invented separately). Never converts an unreadable or
53
+ * missing source root into an empty/zero-file "complete" result: any
54
+ * enumeration failure makes the whole capture "unavailable" with a reason.
55
+ */
56
+ export async function captureIncrementalChangeStalenessSourceState(projectRoot, sourceRoots, treatmentId, benchmarkProjectId) {
57
+ const normalizedSourceRoots = sourceRoots.map(normalizeSourceRoot);
58
+ if (normalizedSourceRoots.length === 0) {
59
+ return {
60
+ schemaVersion: INCREMENTAL_CHANGE_STALENESS_SOURCE_STATE_SCHEMA_VERSION,
61
+ treatmentId,
62
+ benchmarkProjectId,
63
+ sourceRoots: normalizedSourceRoots,
64
+ status: "unavailable",
65
+ files: [],
66
+ fileCount: 0,
67
+ digest: null,
68
+ reason: "No source roots were configured for this benchmark case."
69
+ };
70
+ }
71
+ try {
72
+ const perRoot = await Promise.all(normalizedSourceRoots.map((root) => collectSourceRootFiles(projectRoot, root)));
73
+ const files = perRoot.flat().sort((left, right) => left.relativePath.localeCompare(right.relativePath));
74
+ return {
75
+ schemaVersion: INCREMENTAL_CHANGE_STALENESS_SOURCE_STATE_SCHEMA_VERSION,
76
+ treatmentId,
77
+ benchmarkProjectId,
78
+ sourceRoots: normalizedSourceRoots,
79
+ status: "complete",
80
+ files,
81
+ fileCount: files.length,
82
+ digest: computeDigest(files)
83
+ };
84
+ }
85
+ catch (error) {
86
+ return {
87
+ schemaVersion: INCREMENTAL_CHANGE_STALENESS_SOURCE_STATE_SCHEMA_VERSION,
88
+ treatmentId,
89
+ benchmarkProjectId,
90
+ sourceRoots: normalizedSourceRoots,
91
+ status: "unavailable",
92
+ files: [],
93
+ fileCount: 0,
94
+ digest: null,
95
+ reason: `Unable to capture controlled source state: ${error.message}`
96
+ };
97
+ }
98
+ }
99
+ /**
100
+ * Deterministically compares two controlled source-state captures.
101
+ * `equivalent` requires both sides complete, matching normalized source-root
102
+ * sets, matching path sets, and matching per-file SHA-256 values.
103
+ * Incomplete/unavailable evidence on either side always yields `unknown`,
104
+ * never `equivalent` and never a false `different`.
105
+ */
106
+ export function compareIncrementalChangeStalenessSourceStates(left, right) {
107
+ const reasons = [];
108
+ if (left.status !== "complete") {
109
+ reasons.push(`left source state is unavailable${left.reason ? `: ${left.reason}` : "."}`);
110
+ }
111
+ if (right.status !== "complete") {
112
+ reasons.push(`right source state is unavailable${right.reason ? `: ${right.reason}` : "."}`);
113
+ }
114
+ if (reasons.length > 0) {
115
+ return { result: "unknown", reasons };
116
+ }
117
+ const leftRoots = [...left.sourceRoots].sort();
118
+ const rightRoots = [...right.sourceRoots].sort();
119
+ if (leftRoots.length !== rightRoots.length || leftRoots.some((root, index) => root !== rightRoots[index])) {
120
+ return {
121
+ result: "different",
122
+ reasons: [`source-root contract differs: left=${JSON.stringify(leftRoots)} right=${JSON.stringify(rightRoots)}`]
123
+ };
124
+ }
125
+ const leftByPath = new Map(left.files.map((file) => [file.relativePath, file.sha256]));
126
+ const rightByPath = new Map(right.files.map((file) => [file.relativePath, file.sha256]));
127
+ const differences = [];
128
+ for (const [relativePath, sha256] of leftByPath) {
129
+ if (!rightByPath.has(relativePath)) {
130
+ differences.push(`only on left: ${relativePath}`);
131
+ }
132
+ else if (rightByPath.get(relativePath) !== sha256) {
133
+ differences.push(`content differs: ${relativePath}`);
134
+ }
135
+ }
136
+ for (const relativePath of rightByPath.keys()) {
137
+ if (!leftByPath.has(relativePath)) {
138
+ differences.push(`only on right: ${relativePath}`);
139
+ }
140
+ }
141
+ if (differences.length > 0) {
142
+ return { result: "different", reasons: differences };
143
+ }
144
+ return { result: "equivalent", reasons: [] };
145
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * v0.6.2 Batch 3 in-memory lifecycle session schema version. This is the
3
+ * runtime handoff contract Batch 4 consumes for treatment execution; it is
4
+ * NOT a persisted artifact and carries full graph evidence only in memory.
5
+ */
6
+ export const INCREMENTAL_CHANGE_STALENESS_LIFECYCLE_SESSION_SCHEMA_VERSION = "1.0.0";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * v0.6.3 in-memory (runtime-only) four-treatment lifecycle session schema version. This is NOT the
3
+ * persisted execution artifact; persistence is a later batch.
4
+ */
5
+ export const INCREMENTAL_CHANGE_STALENESS_LIFECYCLE_SESSION_V2_SCHEMA_VERSION = "2.0.0";
@@ -1,2 +1,3 @@
1
1
  export * from "./contextStrategyComparison/index.js";
2
+ export * from "./incrementalChangeStaleness/index.js";
2
3
  export * from "./warmIndexReuse/index.js";
@@ -1,3 +1,4 @@
1
+ import { toAffectedNeighborhoodMetricValues } from "../../../evaluation/affectedNeighborhoodMetrics.js";
1
2
  // ---------------------------------------------------------------------------
2
3
  // Warm-index metric owner. Every warm-index formula (amortization, cumulative
3
4
  // prefix sums, availability) is calculated here exactly once, as a pure
@@ -194,32 +195,28 @@ function warmDirect(task) {
194
195
  };
195
196
  }
196
197
  const NO_AFFECTED_NEIGHBORHOOD_REASON = "No affected-neighborhood assessment was recorded for this task.";
198
+ function wrapAffectedNeighborhoodMetricValue(value) {
199
+ return value.availability === "available"
200
+ ? availableMetric(value.value, value.unit, "derived")
201
+ : unavailableMetric(value.unit, "derived", value.reason);
202
+ }
197
203
  /**
198
204
  * Converts the persisted affected-neighborhood assessment into the six warm-side metrics, once.
199
205
  * A finite value (including zero) is available; an absent assessment or a null field is
200
- * unavailable with a reason, never zero. The metric layer alone owns these value objects.
206
+ * unavailable with a reason, never zero. The formula itself is owned by the shared
207
+ * `toAffectedNeighborhoodMetricValues` (src/evaluation/affectedNeighborhoodMetrics.ts, extracted in
208
+ * v0.6.2 Batch 4 for reuse by incremental-change-staleness); this function only wraps its bounded
209
+ * values into the warm-index `WarmIndexNumberMetricV1` envelope.
201
210
  */
202
211
  function affectedNeighborhoodMetrics(task) {
203
- const assessment = task.affectedNeighborhood;
204
- const one = (field, label) => {
205
- const unit = field === "taskOverlapPercent" ? "percent" : "count";
206
- if (!assessment)
207
- return unavailableMetric(unit, "derived", NO_AFFECTED_NEIGHBORHOOD_REASON);
208
- const value = assessment[field];
209
- if (typeof value === "number")
210
- return availableMetric(value, unit, "derived");
211
- const context = `assessment ${assessment.status}; seed mapping ${assessment.seedMappingStatus}; ` +
212
- `graph ${assessment.graphEvidenceStatus}; neighborhood ${assessment.neighborhoodStatus}; task mapping ${assessment.taskMapping.status}` +
213
- (field === "taskOverlapPercent" ? `; resolvable task nodes ${assessment.taskMapping.resolvableTaskNodeCount}` : "");
214
- return unavailableMetric(unit, "derived", `Affected-neighborhood evidence did not establish ${label} (${context}).`);
215
- };
212
+ const values = toAffectedNeighborhoodMetricValues(task.affectedNeighborhood, NO_AFFECTED_NEIGHBORHOOD_REASON);
216
213
  return {
217
- changedFileCount: one("changedFileCount", "the changed indexed file count"),
218
- changedSymbolCount: one("changedSymbolCount", "the changed baseline symbol count"),
219
- affectedNodeCount: one("affectedNodeCount", "the affected graph node count"),
220
- affectedEdgeCount: one("affectedEdgeCount", "the affected graph edge count"),
221
- taskOverlapCount: one("taskOverlapCount", "the task-overlap node count"),
222
- taskOverlapPercent: one("taskOverlapPercent", "the task-overlap percent"),
214
+ changedFileCount: wrapAffectedNeighborhoodMetricValue(values.changedFileCount),
215
+ changedSymbolCount: wrapAffectedNeighborhoodMetricValue(values.changedSymbolCount),
216
+ affectedNodeCount: wrapAffectedNeighborhoodMetricValue(values.affectedNodeCount),
217
+ affectedEdgeCount: wrapAffectedNeighborhoodMetricValue(values.affectedEdgeCount),
218
+ taskOverlapCount: wrapAffectedNeighborhoodMetricValue(values.taskOverlapCount),
219
+ taskOverlapPercent: wrapAffectedNeighborhoodMetricValue(values.taskOverlapPercent)
223
220
  };
224
221
  }
225
222
  /** Pure, order-preserving calculation; ordinals and cumulative state restart for every project. */
@@ -5,7 +5,9 @@ import { loadAffectedNeighborhoodGraphEvidence } from "../../../evaluation/affec
5
5
  import { captureIndexSnapshot } from "../../../evaluation/indexSnapshot.js";
6
6
  /**
7
7
  * Builds the index once through the shared `buildMyDevKitIndex` owner. A build that reports
8
- * success but leaves no index directory is not treated as a prepared session.
8
+ * success but leaves no index directory is not treated as a prepared session. `mode` is optional
9
+ * and defaults to an ordinary full build; only callers that explicitly request an incremental
10
+ * build mode get one.
9
11
  */
10
12
  export async function prepareWarmIndexSession(options) {
11
13
  const build = await buildMyDevKitIndex(options);
@@ -19,14 +19,21 @@ function buildCandidateEvidenceId(ruleId, location, fingerprint) {
19
19
  const locationPart = [location.path, location.line !== undefined ? `l${location.line}` : undefined, location.column !== undefined ? `c${location.column}` : undefined]
20
20
  .filter((part) => Boolean(part))
21
21
  .join(":");
22
- return [ruleId, slug(locationPart), fingerprint.slice(0, 16)].join("--");
22
+ const payloadStart = fingerprint.indexOf(":") + 1;
23
+ return [ruleId, slug(locationPart), fingerprint.slice(payloadStart, payloadStart + 16)].join("--");
23
24
  }
24
25
  // Never accepts a raw secret value as a plain string parameter name to
25
26
  // discourage accidental logging at call sites — `rawValue` is redacted and
26
27
  // fingerprinted here, once, and the raw value is never stored on the
27
28
  // returned object.
28
29
  export function makeCandidateEvidence(input) {
29
- const fingerprint = fingerprintCandidateValue(input.rawValue);
30
+ const fingerprint = fingerprintCandidateValue(input.rawValue, {
31
+ ruleId: input.ruleId,
32
+ sourcePath: input.location.path,
33
+ line: input.location.line,
34
+ column: input.location.column,
35
+ purpose: "candidate-evidence"
36
+ });
30
37
  const redactedPreview = redactedPreviewForCandidate(input.rawValue);
31
38
  const id = buildCandidateEvidenceId(input.ruleId, input.location, fingerprint);
32
39
  return {
@@ -1,4 +1,5 @@
1
- import { createHash } from "node:crypto";
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 ever being reconstructable — so this module adds
17
- // that prefix/suffix-preserving preview and a non-reversible fingerprint on
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
- // Stable, non-reversible fingerprint for deduplication. Uses Node's built-in
46
- // sha256 (no new dependency). Distinct from redactedPreviewForCandidate: the
47
- // fingerprint is stored for dedup, the preview is stored for human review —
48
- // neither one can be used to recover the raw value.
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 hash = createHash("sha256").update(input, "utf8").digest("hex");
53
- return `sha256:${hash}`;
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
  }
@@ -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`,
@@ -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);