@dailephd/my-dev-kit-lab 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +10 -5
- package/benchmarks/contracts/warm-index-benchmark-cases.json +330 -0
- package/dist/src/cli/help.js +27 -4
- package/dist/src/commands/runExperimentDescribeCommand.js +13 -2
- package/dist/src/commands/runExperimentRunCommand.js +210 -20
- package/dist/src/core/fileGlobs.js +44 -0
- package/dist/src/evaluation/benchmarkMetadata.js +13 -0
- package/dist/src/evaluation/localRepositorySubject/config.js +296 -0
- package/dist/src/evaluation/localRepositorySubject/evaluationCase.js +36 -0
- package/dist/src/evaluation/localRepositorySubject/expectedFiles.js +51 -0
- package/dist/src/evaluation/localRepositorySubject/gitRepository.js +128 -0
- package/dist/src/evaluation/localRepositorySubject/index.js +8 -0
- package/dist/src/evaluation/localRepositorySubject/inventory.js +127 -0
- package/dist/src/evaluation/localRepositorySubject/loadLocalRepositorySubject.js +48 -0
- package/dist/src/evaluation/localRepositorySubject/manifest.js +52 -0
- package/dist/src/evaluation/localRepositorySubject/types.js +55 -0
- package/dist/src/evaluation/projectFileTree.js +45 -1
- package/dist/src/evaluation/retrievalQuality/buildRetrievalEvidence.js +292 -0
- package/dist/src/evaluation/retrievalQuality/corpusCompleteness.js +122 -0
- package/dist/src/evaluation/retrievalQuality/factContextTargets.js +44 -0
- package/dist/src/evaluation/retrievalQuality/index.js +5 -0
- package/dist/src/evaluation/retrievalQuality/metrics.js +233 -0
- package/dist/src/evaluation/retrievalQuality/types.js +9 -0
- package/dist/src/evaluation/runMyDevKitRetrieval.js +6 -1
- package/dist/src/evaluation/runRawFullFileBaseline.js +18 -4
- package/dist/src/evaluation/targetImmutability/captureTargetSnapshot.js +29 -7
- package/dist/src/evaluation/targetImmutability/compareTargetSnapshots.js +4 -0
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/contextWindowScaling/localSubjectErrors.js +35 -0
- package/dist/src/experiments/plugins/contextWindowScaling/localSubjectExclusions.js +75 -0
- package/dist/src/experiments/plugins/contextWindowScaling/localSubjectExecution.js +136 -0
- package/dist/src/experiments/plugins/contextWindowScaling/localSubjectPrivacy.js +128 -0
- package/dist/src/experiments/plugins/contextWindowScaling/localSubjectScratch.js +85 -0
- package/dist/src/experiments/plugins/contextWindowScaling/metadata.js +2 -2
- package/dist/src/experiments/plugins/contextWindowScaling/plugin.js +93 -0
- package/dist/src/experiments/plugins/contextWindowScaling/projectProfile.js +4 -9
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/config.js +39 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/execution.js +152 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/executionArtifact.js +15 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/index.js +9 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectExecution.js +139 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectPrivacy.js +119 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/metadata.js +21 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/metrics.js +158 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/plugin.js +253 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/types.js +8 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +21 -2
- package/dist/src/report/experiments/buildRetrievalPrecisionRecallReport.js +83 -0
- package/dist/src/report/experiments/index.js +4 -0
- package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +7 -3
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +9 -0
- package/dist/src/report/experiments/renderRetrievalPrecisionRecallHtml.js +96 -0
- package/dist/src/report/experiments/renderRetrievalPrecisionRecallText.js +121 -0
- package/dist/src/report/experiments/retrievalPrecisionRecallReportModel.js +3 -0
- package/dist/src/report/experiments/writePluginExperimentReports.js +8 -2
- package/docs/METRICS.md +34 -2
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { parseLocalRepositorySubjectConfig } from "./config.js";
|
|
2
|
+
import { buildLocalRepositorySubjectEvaluationCases } from "./evaluationCase.js";
|
|
3
|
+
import { validateExpectedFiles } from "./expectedFiles.js";
|
|
4
|
+
import { readRepositoryIdentity } from "./gitRepository.js";
|
|
5
|
+
import { buildLocalRepositorySubjectInventory, defaultLocalRepositorySubjectFsIo, validateSourceRootOnDisk, } from "./inventory.js";
|
|
6
|
+
import { buildLocalRepositorySubjectManifest } from "./manifest.js";
|
|
7
|
+
import { DEFAULT_LOCAL_REPOSITORY_SUBJECT_MAX_FILE_BYTES, LocalRepositorySubjectConfigError, compareCodeUnits, logicalTargetRootForSubject, } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Loads a local repository subject in the frozen safety order: validate config, identify the Git repository,
|
|
10
|
+
* validate source roots on disk, inventory (without reading contents), validate required files, then build the
|
|
11
|
+
* privacy-safe manifest and in-memory EvaluationCase objects. Never mutates the subject repository.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadLocalRepositorySubject(options) {
|
|
14
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_LOCAL_REPOSITORY_SUBJECT_MAX_FILE_BYTES;
|
|
15
|
+
if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) {
|
|
16
|
+
throw new LocalRepositorySubjectConfigError([`policy.maxFileBytes: must be a positive safe integer (received ${String(maxFileBytes)}).`]);
|
|
17
|
+
}
|
|
18
|
+
const io = options.io ?? defaultLocalRepositorySubjectFsIo;
|
|
19
|
+
const config = parseLocalRepositorySubjectConfig(options.config);
|
|
20
|
+
const { repositoryRoot, identity } = await readRepositoryIdentity(options.repositoryPath);
|
|
21
|
+
const rootErrors = [];
|
|
22
|
+
for (let caseIndex = 0; caseIndex < config.cases.length; caseIndex += 1) {
|
|
23
|
+
const roots = config.cases[caseIndex].sourceRoots;
|
|
24
|
+
for (let rootIndex = 0; rootIndex < roots.length; rootIndex += 1) {
|
|
25
|
+
rootErrors.push(...(await validateSourceRootOnDisk(repositoryRoot, roots[rootIndex], `config.cases[${caseIndex}].sourceRoots[${rootIndex}]`, io)));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (rootErrors.length > 0)
|
|
29
|
+
throw new LocalRepositorySubjectConfigError(rootErrors);
|
|
30
|
+
const unionRoots = [...new Set(config.cases.flatMap((subjectCase) => subjectCase.sourceRoots))];
|
|
31
|
+
const inventory = await buildLocalRepositorySubjectInventory(repositoryRoot, unionRoots, maxFileBytes, io);
|
|
32
|
+
const expectedFileErrors = await validateExpectedFiles(repositoryRoot, config.cases, inventory, io);
|
|
33
|
+
if (expectedFileErrors.length > 0)
|
|
34
|
+
throw new LocalRepositorySubjectConfigError(expectedFileErrors);
|
|
35
|
+
const manifest = buildLocalRepositorySubjectManifest(config, identity, maxFileBytes, inventory);
|
|
36
|
+
return {
|
|
37
|
+
subjectId: config.subjectId,
|
|
38
|
+
logicalTargetRoot: logicalTargetRootForSubject(config.subjectId),
|
|
39
|
+
repositoryRoot,
|
|
40
|
+
manifest,
|
|
41
|
+
evaluationCases: buildLocalRepositorySubjectEvaluationCases(config, repositoryRoot),
|
|
42
|
+
eligibleFiles: inventory.eligibleFiles.map((file) => file.path),
|
|
43
|
+
runtimeSafetyExclusions: {
|
|
44
|
+
gitIgnoredFiles: [...inventory.ignoredFiles].sort(compareCodeUnits),
|
|
45
|
+
oversizedFiles: [...inventory.oversizedFiles].sort(compareCodeUnits),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { LOCAL_REPOSITORY_SUBJECT_MANIFEST_SCHEMA_ID, LOCAL_REPOSITORY_SUBJECT_MANIFEST_SCHEMA_VERSION, LOCAL_REPOSITORY_SUBJECT_SAFETY_POLICY_VERSION, compareCodeUnits, logicalTargetRootForSubject, } from "./types.js";
|
|
2
|
+
/** Lowercase text after the last "." of the basename; "(none)" when absent or only a leading dot. */
|
|
3
|
+
export function extensionOf(filePath) {
|
|
4
|
+
const base = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
5
|
+
const dot = base.lastIndexOf(".");
|
|
6
|
+
if (dot <= 0 || dot === base.length - 1)
|
|
7
|
+
return "(none)";
|
|
8
|
+
return base.slice(dot + 1).toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
function summarizeExtensions(filePaths) {
|
|
11
|
+
const counts = new Map();
|
|
12
|
+
for (const filePath of filePaths) {
|
|
13
|
+
const extension = extensionOf(filePath);
|
|
14
|
+
counts.set(extension, (counts.get(extension) ?? 0) + 1);
|
|
15
|
+
}
|
|
16
|
+
return [...counts.entries()]
|
|
17
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
18
|
+
.map(([extension, fileCount]) => ({ extension, fileCount }));
|
|
19
|
+
}
|
|
20
|
+
/** Builds the privacy-safe manifest model. Inputs that are runtime-only (paths, file lists) are reduced to aggregates. */
|
|
21
|
+
export function buildLocalRepositorySubjectManifest(config, identity, maxFileBytes, inventory) {
|
|
22
|
+
const sourceRoots = [...new Set(config.cases.flatMap((subjectCase) => subjectCase.sourceRoots))].sort(compareCodeUnits);
|
|
23
|
+
return {
|
|
24
|
+
schemaId: LOCAL_REPOSITORY_SUBJECT_MANIFEST_SCHEMA_ID,
|
|
25
|
+
schemaVersion: LOCAL_REPOSITORY_SUBJECT_MANIFEST_SCHEMA_VERSION,
|
|
26
|
+
subjectId: config.subjectId,
|
|
27
|
+
logicalTargetRoot: logicalTargetRootForSubject(config.subjectId),
|
|
28
|
+
repository: { commit: identity.commit, branch: identity.branch, workingTreeDirty: identity.workingTreeDirty },
|
|
29
|
+
safetyPolicy: {
|
|
30
|
+
version: LOCAL_REPOSITORY_SUBJECT_SAFETY_POLICY_VERSION,
|
|
31
|
+
maxFileBytes,
|
|
32
|
+
gitIgnoreAuthority: "git",
|
|
33
|
+
symlinkPolicy: "excluded-not-followed",
|
|
34
|
+
},
|
|
35
|
+
sourceRoots,
|
|
36
|
+
caseCount: config.cases.length,
|
|
37
|
+
caseIds: config.cases.map((subjectCase) => subjectCase.id),
|
|
38
|
+
inventory: {
|
|
39
|
+
eligibleFileCount: inventory.counts.eligibleFileCount,
|
|
40
|
+
eligibleByteCount: inventory.counts.eligibleByteCount,
|
|
41
|
+
gitIgnoredCount: inventory.counts.gitIgnoredCount,
|
|
42
|
+
oversizedCount: inventory.counts.oversizedCount,
|
|
43
|
+
symlinkCount: inventory.counts.symlinkCount,
|
|
44
|
+
otherExcludedCount: inventory.counts.otherExcludedCount,
|
|
45
|
+
extensionSummary: summarizeExtensions(inventory.eligibleFiles.map((file) => file.path)),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Deterministic serialization (fixed key order from the builder, LF terminated). */
|
|
50
|
+
export function serializeLocalRepositorySubjectManifest(manifest) {
|
|
51
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const LOCAL_REPOSITORY_SUBJECT_CONFIG_SCHEMA_ID = "my-dev-kit-lab-local-repository-subject-config-v1";
|
|
2
|
+
export const LOCAL_REPOSITORY_SUBJECT_CONFIG_SCHEMA_VERSION = "1.0.0";
|
|
3
|
+
export const LOCAL_REPOSITORY_SUBJECT_MANIFEST_SCHEMA_ID = "my-dev-kit-lab-local-repository-subject-manifest-v1";
|
|
4
|
+
export const LOCAL_REPOSITORY_SUBJECT_MANIFEST_SCHEMA_VERSION = "1.0.0";
|
|
5
|
+
/** Operational safety policy (not a quality metric): files larger than this are never eligible subject material. */
|
|
6
|
+
export const DEFAULT_LOCAL_REPOSITORY_SUBJECT_MAX_FILE_BYTES = 1_048_576;
|
|
7
|
+
export const LOCAL_REPOSITORY_SUBJECT_SAFETY_POLICY_VERSION = "1.0.0";
|
|
8
|
+
export const LOCAL_REPOSITORY_SUBJECT_LIMITS = {
|
|
9
|
+
subjectIdMaxLength: 128,
|
|
10
|
+
};
|
|
11
|
+
export const LOCAL_REPOSITORY_SUBJECT_CONFIG_FIELDS = ["schemaVersion", "subjectId", "cases"];
|
|
12
|
+
export const LOCAL_REPOSITORY_SUBJECT_CASE_FIELDS = [
|
|
13
|
+
"id",
|
|
14
|
+
"title",
|
|
15
|
+
"sourceRoots",
|
|
16
|
+
"query",
|
|
17
|
+
"expectedFiles",
|
|
18
|
+
"expectedSymbols",
|
|
19
|
+
"rawIncludeGlobs",
|
|
20
|
+
"answerKey",
|
|
21
|
+
"expectedFacts",
|
|
22
|
+
"taskLocality",
|
|
23
|
+
"promptComplexityHint",
|
|
24
|
+
"projectComplexityRelevance",
|
|
25
|
+
"notes",
|
|
26
|
+
];
|
|
27
|
+
/** Thrown when the config, or required paths it names, fail validation. Messages carry logical labels only. */
|
|
28
|
+
export class LocalRepositorySubjectConfigError extends Error {
|
|
29
|
+
errors;
|
|
30
|
+
constructor(errors) {
|
|
31
|
+
super(`Invalid local repository subject configuration: ${errors.join("; ")}`);
|
|
32
|
+
this.name = "LocalRepositorySubjectConfigError";
|
|
33
|
+
this.errors = [...errors];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Thrown when the selected repository cannot be identified or classified safely. Never carries raw Git output. */
|
|
37
|
+
export class LocalRepositorySubjectRepositoryError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
constructor(code, message) {
|
|
40
|
+
super(`Local repository subject repository error (${code}): ${message}`);
|
|
41
|
+
this.name = "LocalRepositorySubjectRepositoryError";
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function logicalTargetRootForSubject(subjectId) {
|
|
46
|
+
return `local-repository:${subjectId}`;
|
|
47
|
+
}
|
|
48
|
+
/** Locale-independent ordering by code unit. */
|
|
49
|
+
export function compareCodeUnits(left, right) {
|
|
50
|
+
if (left < right)
|
|
51
|
+
return -1;
|
|
52
|
+
if (left > right)
|
|
53
|
+
return 1;
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
@@ -45,8 +45,52 @@ export function countFileLines(filePath) {
|
|
|
45
45
|
}
|
|
46
46
|
return content.split(/\r?\n/).length;
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
function buildAllowedFileTree(root, allowedRelativeFiles) {
|
|
49
|
+
const top = { children: new Map(), isFile: false };
|
|
50
|
+
for (const allowed of allowedRelativeFiles) {
|
|
51
|
+
const relativePath = allowed.replace(/\\/g, "/");
|
|
52
|
+
if (isExcludedProjectPath(relativePath))
|
|
53
|
+
continue;
|
|
54
|
+
let node = top;
|
|
55
|
+
const segments = relativePath.split("/");
|
|
56
|
+
segments.forEach((segment, index) => {
|
|
57
|
+
let child = node.children.get(segment);
|
|
58
|
+
if (!child) {
|
|
59
|
+
child = { children: new Map(), isFile: index === segments.length - 1 };
|
|
60
|
+
node.children.set(segment, child);
|
|
61
|
+
}
|
|
62
|
+
node = child;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const entries = [];
|
|
66
|
+
const walk = (node, prefix) => {
|
|
67
|
+
const names = [...node.children.keys()].sort((a, b) => a.localeCompare(b));
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const child = node.children.get(name);
|
|
70
|
+
const relativePath = prefix === "" ? name : `${prefix}/${name}`;
|
|
71
|
+
if (child.isFile) {
|
|
72
|
+
entries.push({
|
|
73
|
+
path: relativePath,
|
|
74
|
+
kind: "file",
|
|
75
|
+
role: inferFileRole(relativePath, "file"),
|
|
76
|
+
language: inferFileLanguage(relativePath),
|
|
77
|
+
lines: countFileLines(path.join(root, ...relativePath.split("/")))
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
entries.push({ path: relativePath, kind: "directory", role: inferFileRole(relativePath, "directory") });
|
|
82
|
+
walk(child, relativePath);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(top, "");
|
|
87
|
+
return { entries };
|
|
88
|
+
}
|
|
89
|
+
export function buildProjectFileTree(projectRoot, options = {}) {
|
|
49
90
|
const root = path.resolve(projectRoot);
|
|
91
|
+
if (options.allowedRelativeFiles !== undefined) {
|
|
92
|
+
return buildAllowedFileTree(root, options.allowedRelativeFiles);
|
|
93
|
+
}
|
|
50
94
|
const entries = [];
|
|
51
95
|
function walk(currentDir) {
|
|
52
96
|
const dirEntries = readdirSync(currentDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { compareCodeUnits } from "../indexSnapshot.js";
|
|
2
|
+
import { RETRIEVAL_COMMAND_FAMILIES } from "./types.js";
|
|
3
|
+
const MAX_PATH_LENGTH = 1024;
|
|
4
|
+
const REASON_COMMAND_FAILED = "command-failed";
|
|
5
|
+
const REASON_NOT_JSON = "output-is-not-json";
|
|
6
|
+
const REASON_UNSUPPORTED = "output-is-not-the-supported-upstream-schema";
|
|
7
|
+
const REASON_REJECTED = "unsafe-or-invalid-identity-rejected";
|
|
8
|
+
const REASON_NO_SELECTED_FILE = "no-selected-file-for-source-attribution";
|
|
9
|
+
const REASON_SELECTION_UNTRUSTED = "selection-not-backed-by-supported-search-output";
|
|
10
|
+
const REASON_DUPLICATE = "repeated-command-family-ignored";
|
|
11
|
+
/**
|
|
12
|
+
* Normalizes a repository-relative path to the single platform-neutral form (forward slashes, no leading
|
|
13
|
+
* "./"). Returns null for anything that is not a safe repository-relative path: absolute, drive-qualified,
|
|
14
|
+
* UNC, traversing, or containing empty segments. It never repairs an unsafe value into a plausible one.
|
|
15
|
+
*
|
|
16
|
+
* Same contract as the context-window-scaling plugin's `normalizeRepositoryRelativePath`; kept local so the
|
|
17
|
+
* evaluation layer does not import from an experiment plugin. The unit tests assert the two agree.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeRetrievedRepositoryPath(value) {
|
|
20
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_PATH_LENGTH || value.includes("\0")) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
let normalized = value.replace(/\\/g, "/");
|
|
24
|
+
while (normalized.startsWith("./")) {
|
|
25
|
+
normalized = normalized.slice(2);
|
|
26
|
+
}
|
|
27
|
+
if (normalized.length === 0 || normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (normalized.split("/").some((segment) => segment === ".." || segment === "")) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
function isRecord(value) {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
function parseJson(text) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(text);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function symbolKey(name, nodeId, file) {
|
|
47
|
+
return nodeId !== undefined ? `id\0${nodeId}` : `name\0${file ?? ""}\0${name}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reads one upstream node-like entry. Only `file` and `symbol` kinds are evidence; edges and every other
|
|
51
|
+
* kind carry no explicit file/symbol identity of their own and are ignored. Fields are read by their
|
|
52
|
+
* documented names only; identifiers embedded inside other strings are never parsed.
|
|
53
|
+
*/
|
|
54
|
+
function readNode(entry, idKey) {
|
|
55
|
+
if (!isRecord(entry) || typeof entry.kind !== "string") {
|
|
56
|
+
return { type: "rejected" };
|
|
57
|
+
}
|
|
58
|
+
if (entry.kind === "file") {
|
|
59
|
+
const path = normalizeRetrievedRepositoryPath(entry.path);
|
|
60
|
+
return path === null ? { type: "rejected" } : { type: "file", path };
|
|
61
|
+
}
|
|
62
|
+
if (entry.kind === "symbol") {
|
|
63
|
+
const name = typeof entry.symbolName === "string" && entry.symbolName.length > 0 ? entry.symbolName : entry.label;
|
|
64
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
65
|
+
return { type: "rejected" };
|
|
66
|
+
}
|
|
67
|
+
let file;
|
|
68
|
+
if (entry.path !== undefined) {
|
|
69
|
+
const normalized = normalizeRetrievedRepositoryPath(entry.path);
|
|
70
|
+
if (normalized === null) {
|
|
71
|
+
return { type: "rejected" };
|
|
72
|
+
}
|
|
73
|
+
file = normalized;
|
|
74
|
+
}
|
|
75
|
+
const rawId = entry[idKey];
|
|
76
|
+
if (rawId !== undefined && (typeof rawId !== "string" || rawId.length === 0)) {
|
|
77
|
+
return { type: "rejected" };
|
|
78
|
+
}
|
|
79
|
+
return { type: "symbol", name, nodeId: rawId, file };
|
|
80
|
+
}
|
|
81
|
+
return { type: "ignored" };
|
|
82
|
+
}
|
|
83
|
+
function interpretEntries(family, entries, idKey, files, symbols) {
|
|
84
|
+
const result = { parseState: "parsed", files: new Set(), symbolKeys: new Set(), rejected: 0 };
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const reading = readNode(entry, idKey);
|
|
87
|
+
if (reading.type === "ignored")
|
|
88
|
+
continue;
|
|
89
|
+
if (reading.type === "rejected") {
|
|
90
|
+
result.rejected += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (reading.type === "file") {
|
|
94
|
+
addFile(files, reading.path, family);
|
|
95
|
+
result.files.add(reading.path);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const key = symbolKey(reading.name, reading.nodeId, reading.file);
|
|
99
|
+
const existing = symbols.get(key);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.surfacedBy.add(family);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
symbols.set(key, { name: reading.name, nodeId: reading.nodeId, file: reading.file, surfacedBy: new Set([family]) });
|
|
105
|
+
}
|
|
106
|
+
result.symbolKeys.add(key);
|
|
107
|
+
if (reading.file !== undefined) {
|
|
108
|
+
// A symbol node carries an explicit file identity, so that file is surfaced by the same command.
|
|
109
|
+
addFile(files, reading.file, family);
|
|
110
|
+
result.files.add(reading.file);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (result.rejected > 0) {
|
|
114
|
+
result.reason = REASON_REJECTED;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function addFile(files, path, family) {
|
|
119
|
+
const existing = files.get(path);
|
|
120
|
+
if (existing) {
|
|
121
|
+
existing.add(family);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
files.set(path, new Set([family]));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function failedInterpretation(parseState, reason) {
|
|
128
|
+
return { parseState, files: new Set(), symbolKeys: new Set(), rejected: 0, reason };
|
|
129
|
+
}
|
|
130
|
+
function interpretStructured(command, files, symbols) {
|
|
131
|
+
const payload = parseJson(command.stdout);
|
|
132
|
+
if (!isRecord(payload)) {
|
|
133
|
+
return failedInterpretation(payload === undefined ? "malformed" : "unsupported-schema", payload === undefined ? REASON_NOT_JSON : REASON_UNSUPPORTED);
|
|
134
|
+
}
|
|
135
|
+
if (command.family === "search") {
|
|
136
|
+
const supported = payload.artifactKind === "my-dev-kit-v1-search-result" &&
|
|
137
|
+
typeof payload.version === "string" &&
|
|
138
|
+
payload.version.startsWith("1.") &&
|
|
139
|
+
Array.isArray(payload.results);
|
|
140
|
+
return supported
|
|
141
|
+
? interpretEntries("search", payload.results, "nodeId", files, symbols)
|
|
142
|
+
: failedInterpretation("unsupported-schema", REASON_UNSUPPORTED);
|
|
143
|
+
}
|
|
144
|
+
if (command.family === "lookup") {
|
|
145
|
+
const supported = payload.status === "found" && isRecord(payload.node) && Array.isArray(payload.neighbors);
|
|
146
|
+
return supported
|
|
147
|
+
? interpretEntries("lookup", [payload.node, ...payload.neighbors], "id", files, symbols)
|
|
148
|
+
: failedInterpretation("unsupported-schema", REASON_UNSUPPORTED);
|
|
149
|
+
}
|
|
150
|
+
const supported = payload.artifactKind === "my-dev-kit-v1-graph-slice" && typeof payload.version === "string" && payload.version.startsWith("1.") && Array.isArray(payload.nodes);
|
|
151
|
+
return supported
|
|
152
|
+
? interpretEntries("slice", payload.nodes, "id", files, symbols)
|
|
153
|
+
: failedInterpretation("unsupported-schema", REASON_UNSUPPORTED);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Source output is context text. Evidence is attributed only from the trusted selection the lifecycle
|
|
157
|
+
* passed to the source command: its selected file, and the symbol already surfaced by structured output
|
|
158
|
+
* under the same node id. Nothing is read from the source body.
|
|
159
|
+
*/
|
|
160
|
+
function attributeSource(selection, searchInterpreted, files, symbols) {
|
|
161
|
+
if (!searchInterpreted) {
|
|
162
|
+
// The selection was derived from search output; without supported search evidence it is not trusted.
|
|
163
|
+
return failedInterpretation("unattributable", REASON_SELECTION_UNTRUSTED);
|
|
164
|
+
}
|
|
165
|
+
if (selection?.file === undefined) {
|
|
166
|
+
return failedInterpretation("unattributable", REASON_NO_SELECTED_FILE);
|
|
167
|
+
}
|
|
168
|
+
const path = normalizeRetrievedRepositoryPath(selection.file);
|
|
169
|
+
if (path === null) {
|
|
170
|
+
return { ...failedInterpretation("unattributable", REASON_REJECTED), rejected: 1 };
|
|
171
|
+
}
|
|
172
|
+
const result = { parseState: "selection-attributed", files: new Set([path]), symbolKeys: new Set(), rejected: 0 };
|
|
173
|
+
addFile(files, path, "source");
|
|
174
|
+
if (selection.nodeId !== undefined) {
|
|
175
|
+
const key = symbolKey("", selection.nodeId, undefined);
|
|
176
|
+
const known = symbols.get(key);
|
|
177
|
+
if (known) {
|
|
178
|
+
known.surfacedBy.add("source");
|
|
179
|
+
result.symbolKeys.add(key);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
function orderedProvenance(provenance) {
|
|
185
|
+
return RETRIEVAL_COMMAND_FAMILIES.filter((family) => provenance.has(family));
|
|
186
|
+
}
|
|
187
|
+
function isFullyInterpreted(entry) {
|
|
188
|
+
return (entry.parseState === "parsed" || entry.parseState === "selection-attributed") && entry.rejectedIdentityCount === 0;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Pure policy: builds normalized retrieval evidence from the commands the lifecycle executed.
|
|
192
|
+
* Deterministic: output ordering depends only on code-unit comparison of identities and the fixed
|
|
193
|
+
* search -> lookup -> slice -> source family order, never on input order or object enumeration.
|
|
194
|
+
*/
|
|
195
|
+
export function buildRetrievalEvidence(input) {
|
|
196
|
+
const files = new Map();
|
|
197
|
+
const symbols = new Map();
|
|
198
|
+
const interpreted = new Map();
|
|
199
|
+
const duplicates = [];
|
|
200
|
+
const ordered = RETRIEVAL_COMMAND_FAMILIES.flatMap((family) => input.commands.filter((command) => command.family === family));
|
|
201
|
+
// Symbol attribution for source depends on structured evidence, so structured families run first (they do
|
|
202
|
+
// by family order); source is last.
|
|
203
|
+
for (const command of ordered) {
|
|
204
|
+
if (interpreted.has(command.family)) {
|
|
205
|
+
duplicates.push(command.family);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
let outcome;
|
|
209
|
+
if (!command.ok) {
|
|
210
|
+
outcome = failedInterpretation("command-failed", REASON_COMMAND_FAILED);
|
|
211
|
+
}
|
|
212
|
+
else if (command.family === "source") {
|
|
213
|
+
outcome = attributeSource(input.selection, interpreted.get("search")?.parseState === "parsed", files, symbols);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
outcome = interpretStructured(command, files, symbols);
|
|
217
|
+
}
|
|
218
|
+
interpreted.set(command.family, {
|
|
219
|
+
family: command.family,
|
|
220
|
+
succeeded: command.ok,
|
|
221
|
+
parseState: outcome.parseState,
|
|
222
|
+
fileEvidenceCount: outcome.files.size,
|
|
223
|
+
symbolEvidenceCount: outcome.symbolKeys.size,
|
|
224
|
+
rejectedIdentityCount: outcome.rejected,
|
|
225
|
+
...(outcome.reason !== undefined ? { reason: outcome.reason } : {})
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const commands = RETRIEVAL_COMMAND_FAMILIES.flatMap((family) => {
|
|
229
|
+
const entry = interpreted.get(family);
|
|
230
|
+
return entry ? [entry] : [];
|
|
231
|
+
});
|
|
232
|
+
const fileEvidence = [...files.entries()]
|
|
233
|
+
.map(([path, provenance]) => ({ path, surfacedBy: orderedProvenance(provenance) }))
|
|
234
|
+
.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
235
|
+
const symbolEvidence = [...symbols.values()]
|
|
236
|
+
.map((symbol) => ({
|
|
237
|
+
name: symbol.name,
|
|
238
|
+
...(symbol.nodeId !== undefined ? { nodeId: symbol.nodeId } : {}),
|
|
239
|
+
...(symbol.file !== undefined ? { file: symbol.file } : {}),
|
|
240
|
+
surfacedBy: orderedProvenance(symbol.surfacedBy)
|
|
241
|
+
}))
|
|
242
|
+
.sort((left, right) => compareCodeUnits(left.file ?? "", right.file ?? "") ||
|
|
243
|
+
compareCodeUnits(left.name, right.name) ||
|
|
244
|
+
compareCodeUnits(left.nodeId ?? "", right.nodeId ?? ""));
|
|
245
|
+
const search = interpreted.get("search");
|
|
246
|
+
let availability;
|
|
247
|
+
let availabilityReason;
|
|
248
|
+
if (commands.length === 0) {
|
|
249
|
+
availability = "unavailable";
|
|
250
|
+
availabilityReason = "no-retrieval-commands-executed";
|
|
251
|
+
}
|
|
252
|
+
else if (!search) {
|
|
253
|
+
availability = "unavailable";
|
|
254
|
+
availabilityReason = "search-command-not-executed";
|
|
255
|
+
}
|
|
256
|
+
else if (search.parseState !== "parsed") {
|
|
257
|
+
availability = "unavailable";
|
|
258
|
+
availabilityReason = "search-evidence-unavailable";
|
|
259
|
+
}
|
|
260
|
+
else if (duplicates.length > 0 || !commands.every(isFullyInterpreted)) {
|
|
261
|
+
availability = "partial";
|
|
262
|
+
availabilityReason = "some-executed-commands-were-not-fully-interpreted";
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
availability = "available";
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
schemaVersion: "retrieval-evidence-v1",
|
|
269
|
+
availability,
|
|
270
|
+
...(availabilityReason !== undefined ? { availabilityReason } : {}),
|
|
271
|
+
files: fileEvidence,
|
|
272
|
+
symbols: symbolEvidence,
|
|
273
|
+
commands: commands.map((entry) => duplicates.includes(entry.family) && entry.reason === undefined ? { ...entry, reason: REASON_DUPLICATE } : entry)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Thin adapter from the lifecycle's measured commands to the pure builder. Only the four retrieval
|
|
278
|
+
* families are considered; `index`, `version`, and any other command are ignored. Source stdout is dropped
|
|
279
|
+
* here on purpose.
|
|
280
|
+
*/
|
|
281
|
+
export function buildRetrievalEvidenceFromCommands(commands, selection) {
|
|
282
|
+
const inputs = [];
|
|
283
|
+
for (const command of commands) {
|
|
284
|
+
if (command.commandId === "source") {
|
|
285
|
+
inputs.push({ family: "source", ok: command.ok });
|
|
286
|
+
}
|
|
287
|
+
else if (command.commandId === "search" || command.commandId === "lookup" || command.commandId === "slice") {
|
|
288
|
+
inputs.push({ family: command.commandId, ok: command.ok, stdout: command.stdout });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return buildRetrievalEvidence({ commands: inputs, selection });
|
|
292
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { normalizeRetrievedRepositoryPath } from "./buildRetrievalEvidence.js";
|
|
2
|
+
import { hasFactMapping, interpretFactContextTarget } from "./factContextTargets.js";
|
|
3
|
+
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
4
|
+
function normalizedSet(values, normalize) {
|
|
5
|
+
if (!Array.isArray(values))
|
|
6
|
+
return undefined;
|
|
7
|
+
const result = new Set();
|
|
8
|
+
for (const value of values) {
|
|
9
|
+
const normalized = normalize(value);
|
|
10
|
+
if (normalized === null)
|
|
11
|
+
return undefined;
|
|
12
|
+
result.add(normalized);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
const plainString = (value) => (typeof value === "string" && value.length > 0 ? value : null);
|
|
17
|
+
/**
|
|
18
|
+
* Ground-truth completeness for one retrieval-precision-recall case. Returns bounded error strings that never
|
|
19
|
+
* echo unsafe values. This rule set applies only to corpora that opt into retrieval-precision-recall; it is not
|
|
20
|
+
* a global answer-key rule.
|
|
21
|
+
*/
|
|
22
|
+
export function validateRetrievalPrecisionRecallCase(evaluationCase) {
|
|
23
|
+
const label = `retrieval-precision-recall case ${typeof evaluationCase.id === "string" ? evaluationCase.id : "<unknown>"}`;
|
|
24
|
+
const answerKey = asRecord(evaluationCase.answerKey);
|
|
25
|
+
if (!answerKey)
|
|
26
|
+
return [`${label}: answerKey is required.`];
|
|
27
|
+
const errors = [];
|
|
28
|
+
const expectedFiles = normalizedSet(answerKey.expectedFiles, normalizeRetrievedRepositoryPath);
|
|
29
|
+
const expectedSymbols = normalizedSet(answerKey.expectedSymbols, plainString);
|
|
30
|
+
if (expectedFiles === undefined || expectedFiles.size === 0)
|
|
31
|
+
errors.push(`${label}: answerKey.expectedFiles must be a nonempty array of safe repository-relative paths.`);
|
|
32
|
+
if (expectedSymbols === undefined || expectedSymbols.size === 0)
|
|
33
|
+
errors.push(`${label}: answerKey.expectedSymbols must be a nonempty array of nonempty strings.`);
|
|
34
|
+
const topFiles = normalizedSet(evaluationCase.expectedFiles, normalizeRetrievedRepositoryPath);
|
|
35
|
+
const topSymbols = normalizedSet(evaluationCase.expectedSymbols, plainString);
|
|
36
|
+
const sameSet = (left, right) => left !== undefined && right !== undefined && left.size === right.size && [...left].every((value) => right.has(value));
|
|
37
|
+
if (expectedFiles !== undefined && !sameSet(topFiles, expectedFiles))
|
|
38
|
+
errors.push(`${label}: expectedFiles must agree with answerKey.expectedFiles.`);
|
|
39
|
+
if (expectedSymbols !== undefined && !sameSet(topSymbols, expectedSymbols))
|
|
40
|
+
errors.push(`${label}: expectedSymbols must agree with answerKey.expectedSymbols.`);
|
|
41
|
+
const facts = answerKey.expectedFacts;
|
|
42
|
+
const factIds = [];
|
|
43
|
+
if (!Array.isArray(facts) || facts.length === 0) {
|
|
44
|
+
errors.push(`${label}: answerKey.expectedFacts must be a nonempty array.`);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
for (const fact of facts) {
|
|
48
|
+
const id = asRecord(fact)?.id;
|
|
49
|
+
if (typeof id !== "string" || id.length === 0 || factIds.includes(id)) {
|
|
50
|
+
errors.push(`${label}: answerKey.expectedFacts must have unique nonempty ids.`);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
factIds.push(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const targets = answerKey.expectedContextTargets;
|
|
57
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
58
|
+
errors.push(`${label}: answerKey.expectedContextTargets is required for fact coverage.`);
|
|
59
|
+
return errors;
|
|
60
|
+
}
|
|
61
|
+
const known = new Set(factIds);
|
|
62
|
+
const requiredMappedFacts = new Set();
|
|
63
|
+
targets.forEach((target, index) => {
|
|
64
|
+
const targetLabel = `${label}: answerKey.expectedContextTargets[${index}]`;
|
|
65
|
+
const record = asRecord(target);
|
|
66
|
+
if (!record) {
|
|
67
|
+
errors.push(`${targetLabel}: target must be an object.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (hasFactMapping(target)) {
|
|
71
|
+
const interpretation = interpretFactContextTarget(target, known);
|
|
72
|
+
if (!interpretation.ok) {
|
|
73
|
+
errors.push(`${targetLabel}: ${interpretation.problem}.`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (interpretation.target.required)
|
|
77
|
+
interpretation.target.factIds.forEach((id) => requiredMappedFacts.add(id));
|
|
78
|
+
}
|
|
79
|
+
// No hidden expectation outside the answer key, mapped or not.
|
|
80
|
+
const file = normalizeRetrievedRepositoryPath(record.file);
|
|
81
|
+
if (file === null) {
|
|
82
|
+
errors.push(`${targetLabel}: file must be a safe repository-relative path.`);
|
|
83
|
+
}
|
|
84
|
+
else if (expectedFiles !== undefined && !expectedFiles.has(file)) {
|
|
85
|
+
errors.push(`${targetLabel}: file must be listed in answerKey.expectedFiles.`);
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(record.symbols) && expectedSymbols !== undefined) {
|
|
88
|
+
for (const symbol of record.symbols) {
|
|
89
|
+
if (typeof symbol !== "string" || !expectedSymbols.has(symbol)) {
|
|
90
|
+
errors.push(`${targetLabel}: every symbol must be listed in answerKey.expectedSymbols.`);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
for (const id of factIds) {
|
|
97
|
+
if (!requiredMappedFacts.has(id))
|
|
98
|
+
errors.push(`${label}: expected fact ${id} has no required fact-mapped context target.`);
|
|
99
|
+
}
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
102
|
+
/** Validates every case of a production retrieval-precision-recall corpus; case ids must be unique. */
|
|
103
|
+
export function validateRetrievalPrecisionRecallCorpus(cases) {
|
|
104
|
+
const errors = [];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const evaluationCase of cases) {
|
|
107
|
+
if (seen.has(evaluationCase.id))
|
|
108
|
+
errors.push(`retrieval-precision-recall corpus: duplicate case id ${evaluationCase.id}.`);
|
|
109
|
+
seen.add(evaluationCase.id);
|
|
110
|
+
errors.push(...validateRetrievalPrecisionRecallCase(evaluationCase));
|
|
111
|
+
}
|
|
112
|
+
return errors;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Per-case issue counts without any identity text. Use this, never the detailed messages, when reporting about an
|
|
116
|
+
* external private subject: the detailed messages may name expected facts.
|
|
117
|
+
*/
|
|
118
|
+
export function summarizeRetrievalGroundTruthIssuesSafely(cases) {
|
|
119
|
+
return cases
|
|
120
|
+
.map((evaluationCase) => ({ caseId: evaluationCase.id, issueCount: validateRetrievalPrecisionRecallCase(evaluationCase).length }))
|
|
121
|
+
.filter((entry) => entry.issueCount > 0);
|
|
122
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { normalizeRetrievedRepositoryPath } from "./buildRetrievalEvidence.js";
|
|
2
|
+
/** True when the target participates in fact mapping, i.e. it carries a `factIds` field. */
|
|
3
|
+
export function hasFactMapping(target) {
|
|
4
|
+
return typeof target === "object" && target !== null && !Array.isArray(target) && target.factIds !== undefined;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Single owner of the `ExpectedContextTarget.factIds` contract. Pure. Problems are fixed bounded strings and
|
|
8
|
+
* never echo the offending value. Unsafe file identities are rejected, never repaired.
|
|
9
|
+
*/
|
|
10
|
+
export function interpretFactContextTarget(target, knownFactIds) {
|
|
11
|
+
if (typeof target !== "object" || target === null || Array.isArray(target)) {
|
|
12
|
+
return { ok: false, problem: "target must be an object" };
|
|
13
|
+
}
|
|
14
|
+
const record = target;
|
|
15
|
+
const file = normalizeRetrievedRepositoryPath(record.file);
|
|
16
|
+
if (file === null) {
|
|
17
|
+
return { ok: false, problem: "file must be a safe repository-relative path" };
|
|
18
|
+
}
|
|
19
|
+
const rawFactIds = record.factIds;
|
|
20
|
+
if (!Array.isArray(rawFactIds) || rawFactIds.length === 0 || rawFactIds.some((id) => typeof id !== "string" || id.length === 0)) {
|
|
21
|
+
return { ok: false, problem: "factIds must be a nonempty array of nonempty strings" };
|
|
22
|
+
}
|
|
23
|
+
const factIds = rawFactIds;
|
|
24
|
+
if (new Set(factIds).size !== factIds.length) {
|
|
25
|
+
return { ok: false, problem: "factIds must not contain duplicates" };
|
|
26
|
+
}
|
|
27
|
+
if (factIds.some((id) => !knownFactIds.has(id))) {
|
|
28
|
+
return { ok: false, problem: "factIds must reference existing expectedFacts ids" };
|
|
29
|
+
}
|
|
30
|
+
let symbols = [];
|
|
31
|
+
if (record.symbols !== undefined) {
|
|
32
|
+
if (!Array.isArray(record.symbols) || record.symbols.some((symbol) => typeof symbol !== "string" || symbol.length === 0)) {
|
|
33
|
+
return { ok: false, problem: "symbols must be an array of nonempty strings" };
|
|
34
|
+
}
|
|
35
|
+
symbols = record.symbols;
|
|
36
|
+
if (new Set(symbols).size !== symbols.length) {
|
|
37
|
+
return { ok: false, problem: "symbols must not contain duplicates" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (record.required !== undefined && typeof record.required !== "boolean") {
|
|
41
|
+
return { ok: false, problem: "required must be a boolean when present" };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, target: { file, symbols: [...symbols], required: record.required !== false, factIds: [...factIds] } };
|
|
44
|
+
}
|