@thomasminh1995/depverdict 0.6.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +518 -0
- package/bin/depverdict.js +7 -0
- package/bin/upgradelens.js +7 -0
- package/docs/GR-01-Semantic-Grounding-Failure-Analysis.md +309 -0
- package/docs/GR-02-Versioned-Action-Evaluation-Criteria.md +187 -0
- package/docs/GR-03-Extractive-Contract-Safety-Experiment.md +242 -0
- package/docs/GR-04-Versioned-Production-Extractive-Contract.md +154 -0
- package/docs/IA-01-Repository-Usage-Discovery.md +122 -0
- package/docs/IA-02-Repository-Impact-Analysis.md +118 -0
- package/docs/IA-03-Repository-Impact-Evidence.md +160 -0
- package/docs/IA-04-CLI-Orchestration.md +235 -0
- package/docs/IA-05-Real-Provider-Validation.md +235 -0
- package/docs/IA-05-VinGrade-Validation.md +339 -0
- package/docs/MVP-01.md +48 -0
- package/docs/MVP-02-Architecture.md +536 -0
- package/docs/MVP-02-CLI-HTTP-Runtime.md +50 -0
- package/docs/MVP-02-HTTP-Lifecycle.md +37 -0
- package/docs/MVP-02-Knowledge-Manifest-Generation.md +75 -0
- package/docs/MVP-02-Knowledge-Manifest.md +279 -0
- package/docs/MVP-02-Knowledge-Research-Orchestration.md +62 -0
- package/docs/MVP-02-Knowledge-Store.md +87 -0
- package/docs/MVP-02-PyPI-Registry-Adapter.md +97 -0
- package/docs/MVP-02-Research-Planning.md +196 -0
- package/docs/MVP-02-Source-Provenance.md +81 -0
- package/docs/MVP-02-npm-Registry-Adapter.md +99 -0
- package/docs/RR-01-End-to-End-and-Real-Provider-Validation.md +393 -0
- package/docs/RR-01-RERUN-Extractive-Contract-Validation.md +566 -0
- package/docs/RR-02-Full-Product-Workflow-and-Developer-CLI-UX-Review.md +35 -0
- package/docs/RR02-FIX-01-Persistent-Qualification-Resolution.md +32 -0
- package/docs/RR02-FIX-02-Stage-aware-CLI-Progress-and-Heartbeat.md +28 -0
- package/docs/RR02-FIX-03-npm-Capture-Evidence-Exclusion.md +33 -0
- package/docs/RR02-FIX-03A-Complete-Package-Exclusion-and-Evidence-Commit.md +223 -0
- package/docs/RR02-FIX-04-Event-loop-safe-Heartbeat.md +393 -0
- package/docs/RR02-FIX-05-Materialize-Persisted-Qualification.md +207 -0
- package/docs/RR02-RERUN-CLI-Qualification-Progress-UX-and-Package-Validation.md +342 -0
- package/docs/VinGrade-MVP-02-Validation.md +480 -0
- package/docs/VinGrade-RC02-Live-Validation.md +273 -0
- package/docs/ai-capability-discovery.md +573 -0
- package/docs/ai-engineering-production-readiness.md +390 -0
- package/docs/ai-engineering-review.md +251 -0
- package/docs/ai-runtime-governance-discovery.md +754 -0
- package/docs/architecture-overview.md +78 -0
- package/docs/cli-progress.md +111 -0
- package/docs/decisions/diff-01-brand-distribution-identity.md +450 -0
- package/docs/decisions/diff-02-identity-compatibility-contract.md +274 -0
- package/docs/decisions/diff-03-repository-docs-community-migration.md +165 -0
- package/docs/decisions/diff-04-release-evidence-gap-acceptance.md +110 -0
- package/docs/discovery/mvp-05-ai-migration-planning-discovery.md +350 -0
- package/docs/gateway-runtime-discovery.md +614 -0
- package/docs/live-ai-validation.md +299 -0
- package/docs/migration-planning-qualification-resolution.md +99 -0
- package/docs/migrations/upgradelens-to-depverdict.md +97 -0
- package/docs/mp-r03-deterministic-upgrade-decision-architecture.md +135 -0
- package/docs/mp-r04-evidence-bounded-migration-handoff-architecture.md +116 -0
- package/docs/mp-r05-product-completion-and-decision-first-cli-architecture.md +162 -0
- package/docs/mvp-05-deterministic-context-runtime.md +127 -0
- package/docs/mvp-05-migration-checklist-contract.md +166 -0
- package/docs/mvp-05-migration-checklist-orchestration.md +105 -0
- package/docs/mvp-05-migration-evaluation-and-qualification.md +135 -0
- package/docs/mvp-05-provider-neutral-generator.md +79 -0
- package/docs/ollama-local-smoke-validation.md +156 -0
- package/docs/openai-compatible-runtime-discovery.md +789 -0
- package/docs/openrouter-one-dependency-validation.md +205 -0
- package/docs/oss-02-package-guard-hardening-architecture.md +131 -0
- package/docs/oss-04-public-ci-package-metadata-architecture.md +142 -0
- package/docs/package-content-policy.md +98 -0
- package/docs/releases/v0.5.0-technical-preview.md +114 -0
- package/docs/releases/v0.6.0-alpha.1-depverdict-preview.md +101 -0
- package/docs/reviews/diff-02-identity-contract-compatibility.md +413 -0
- package/docs/reviews/diff-03-repository-docs-community-migration.md +364 -0
- package/docs/reviews/diff-04-depverdict-distribution-identity-readiness-rereview.md +453 -0
- package/docs/reviews/diff-04-fix-post-rename-identity-release-remediation.md +310 -0
- package/docs/reviews/diff-05-final-preview-distribution-qualification.md +561 -0
- package/docs/reviews/mvp-05-final-product-value-workflow-rereview.md +471 -0
- package/docs/reviews/mvp-05-product-workflow-review.md +605 -0
- package/docs/reviews/oss-01-duplicate-artifact-investigation-cleanup.md +411 -0
- package/docs/reviews/oss-02-package-guard-hardening.md +303 -0
- package/docs/reviews/oss-03-community-scaffolding.md +378 -0
- package/docs/reviews/oss-04-public-ci-package-metadata.md +439 -0
- package/docs/reviews/oss-05-technical-preview-qualification.md +482 -0
- package/docs/reviews/upgradelens-vs-upgradedepdetective-source-comparison.md +355 -0
- package/docs/reviews/v0.5.0-pre-release-smoke.md +263 -0
- package/docs/reviews/v0.5.0-version-bump-release-verification.md +306 -0
- package/docs/runtime-contract-discovery.md +521 -0
- package/docs/structured-output-compatibility-report.md +100 -0
- package/docs/ts-fix-01-exact-duplicate-occurrence-target-selection-architecture.md +111 -0
- package/docs/version-analysis-architecture.md +827 -0
- package/eval/README.md +86 -0
- package/eval/datasets/generic/declared-constraint.json +59 -0
- package/eval/datasets/generic/evidence-conflict.json +73 -0
- package/eval/datasets/generic/major-breaking-release.json +66 -0
- package/eval/datasets/generic/missing-evidence.json +46 -0
- package/eval/datasets/generic/patch-release-low.json +59 -0
- package/eval/datasets/node/axios-patch-low.json +59 -0
- package/eval/datasets/node/react-major-breaking.json +66 -0
- package/eval/datasets/node/react-minor-compatibility.json +66 -0
- package/eval/datasets/python/fastapi-deprecation.json +66 -0
- package/eval/datasets/python/pydantic-major-breaking.json +66 -0
- package/eval/migration-planning/golden-dataset-v2.json +306 -0
- package/eval/migration-planning/golden-dataset.json +214 -0
- package/eval/schemas/expected-result.schema.json +88 -0
- package/eval/schemas/golden-case.schema.json +181 -0
- package/package.json +57 -0
- package/schemas/ai-scorecard.schema.json +132 -0
- package/schemas/benchmark-report.schema.json +189 -0
- package/schemas/benchmark.schema.json +55 -0
- package/schemas/capability-profile.schema.json +44 -0
- package/schemas/conformance-report.schema.json +150 -0
- package/schemas/deployment-profile.schema.json +64 -0
- package/schemas/evaluation-report.schema.json +158 -0
- package/schemas/knowledge-evidence-bundle.schema.json +150 -0
- package/schemas/knowledge-manifest.schema.json +548 -0
- package/schemas/metrics.schema.json +178 -0
- package/schemas/migration-checklist-extractive-candidate.schema.json +42 -0
- package/schemas/migration-checklist.schema.json +706 -0
- package/schemas/migration-evaluation-dataset-v2.schema.json +208 -0
- package/schemas/migration-evaluation-dataset.schema.json +204 -0
- package/schemas/migration-planning-qualification-record.schema.json +234 -0
- package/schemas/project-manifest.schema.json +308 -0
- package/schemas/qualification-record.schema.json +56 -0
- package/schemas/repository-impact-evidence.schema.json +232 -0
- package/schemas/repository-impact.schema.json +202 -0
- package/schemas/upgrade-decision.schema.json +273 -0
- package/schemas/usage-index.schema.json +179 -0
- package/schemas/version-analysis.schema.json +449 -0
- package/src/ai-runtime-debug.js +325 -0
- package/src/ai-runtime-error.js +42 -0
- package/src/ai-runtime.js +174 -0
- package/src/ai-scorecard.js +204 -0
- package/src/ai-version-analysis.js +484 -0
- package/src/artifact-root-compatibility.js +91 -0
- package/src/benchmark-report.js +111 -0
- package/src/benchmark-runner.js +191 -0
- package/src/canonical-json.js +69 -0
- package/src/cli.js +1299 -0
- package/src/conformance-report.js +158 -0
- package/src/conformance-runner.js +253 -0
- package/src/constants.js +73 -0
- package/src/cooperative-scheduler.js +79 -0
- package/src/dependencies.js +44 -0
- package/src/dependency-ai-context.js +625 -0
- package/src/detectors.js +253 -0
- package/src/discovery.js +234 -0
- package/src/ecosystem-version-adapter.js +294 -0
- package/src/environment-compatibility.js +77 -0
- package/src/evaluation-comparator.js +158 -0
- package/src/evaluation-report.js +76 -0
- package/src/evaluation-runner.js +248 -0
- package/src/evidence-source-adapter.js +472 -0
- package/src/files.js +89 -0
- package/src/governance-diagnostics.js +64 -0
- package/src/governance-loader.js +63 -0
- package/src/governance-metadata.js +346 -0
- package/src/governance-validator.js +360 -0
- package/src/http/bounded-fetch.js +278 -0
- package/src/http/cli-http-runtime.js +44 -0
- package/src/impact/input-loader.js +157 -0
- package/src/impact/matcher.js +40 -0
- package/src/impact/repository-impact.js +199 -0
- package/src/impact/runtime.js +24 -0
- package/src/impact/status.js +62 -0
- package/src/impact/writer.js +30 -0
- package/src/impact-evidence/input-loader.js +202 -0
- package/src/impact-evidence/repository-impact-evidence.js +234 -0
- package/src/impact-evidence/runtime.js +16 -0
- package/src/impact-evidence/writer.js +30 -0
- package/src/index.js +563 -0
- package/src/installed-version-baseline.js +196 -0
- package/src/knowledge-cache.js +324 -0
- package/src/knowledge-evidence-bundle.js +101 -0
- package/src/knowledge-evidence-producer.js +233 -0
- package/src/knowledge-manifest-builder.js +188 -0
- package/src/knowledge-manifest-writer.js +32 -0
- package/src/knowledge-manifest.js +255 -0
- package/src/knowledge-research.js +615 -0
- package/src/metrics-engine.js +205 -0
- package/src/migration-checklist/ai-candidate.js +320 -0
- package/src/migration-checklist/assembler.js +37 -0
- package/src/migration-checklist/context-runtime.js +828 -0
- package/src/migration-checklist/evaluation/action-criteria.js +244 -0
- package/src/migration-checklist/evaluation/comparator-v2.js +332 -0
- package/src/migration-checklist/evaluation/comparator.js +279 -0
- package/src/migration-checklist/evaluation/dataset-v2.js +227 -0
- package/src/migration-checklist/evaluation/dataset.js +336 -0
- package/src/migration-checklist/evaluation/extractive-fixtures-v2.js +148 -0
- package/src/migration-checklist/evaluation/metrics-v2.js +226 -0
- package/src/migration-checklist/evaluation/metrics.js +158 -0
- package/src/migration-checklist/evaluation/qualification-v2.js +321 -0
- package/src/migration-checklist/evaluation/qualification.js +239 -0
- package/src/migration-checklist/evaluation/runner-v2.js +294 -0
- package/src/migration-checklist/evaluation/runner.js +194 -0
- package/src/migration-checklist/evaluation/scorecard-v2.js +106 -0
- package/src/migration-checklist/evaluation/scorecard.js +86 -0
- package/src/migration-checklist/extractive-candidate.js +166 -0
- package/src/migration-checklist/extractive-prompt.js +62 -0
- package/src/migration-checklist/generator.js +702 -0
- package/src/migration-checklist/grounding-policy.js +117 -0
- package/src/migration-checklist/input-loader.js +613 -0
- package/src/migration-checklist/migration-checklist.js +635 -0
- package/src/migration-checklist/presentation.js +292 -0
- package/src/migration-checklist/progress.js +96 -0
- package/src/migration-checklist/prompt.js +83 -0
- package/src/migration-checklist/qualification-guard.js +462 -0
- package/src/migration-checklist/qualification-resolution.js +122 -0
- package/src/migration-checklist/qualification-store.js +225 -0
- package/src/migration-checklist/runtime.js +205 -0
- package/src/migration-checklist/verification.js +134 -0
- package/src/migration-checklist/writer.js +35 -0
- package/src/openai-compatible-provider.js +451 -0
- package/src/orchestration/failure-log.js +32 -0
- package/src/orchestration/pipeline.js +200 -0
- package/src/orchestration/progress-events.js +337 -0
- package/src/orchestration/progress-reporter.js +131 -0
- package/src/orchestration/text-writer.js +22 -0
- package/src/portable.js +13 -0
- package/src/product-completion.js +249 -0
- package/src/project-manifest-input.js +90 -0
- package/src/project-manifest.js +141 -0
- package/src/python-requirements.js +137 -0
- package/src/registry/npm-packument.js +256 -0
- package/src/registry/npm-registry-adapter.js +262 -0
- package/src/registry/pypi-project.js +300 -0
- package/src/registry/pypi-registry-adapter.js +235 -0
- package/src/registry/sanitize-registry-body.js +51 -0
- package/src/renderers/console.js +160 -0
- package/src/renderers/impact-presentation.js +278 -0
- package/src/renderers/markdown.js +172 -0
- package/src/research-plan.js +455 -0
- package/src/runtime-conformance.js +275 -0
- package/src/source-provenance.js +393 -0
- package/src/source-url.js +62 -0
- package/src/structured-output-schema.js +66 -0
- package/src/target-selector.js +306 -0
- package/src/upgrade-decision/input-loader.js +107 -0
- package/src/upgrade-decision/presentation.js +43 -0
- package/src/upgrade-decision/runtime.js +21 -0
- package/src/upgrade-decision/upgrade-decision.js +626 -0
- package/src/upgrade-decision/writer.js +30 -0
- package/src/usage/analyzer-registry.js +63 -0
- package/src/usage/coverage.js +116 -0
- package/src/usage/input-loader.js +139 -0
- package/src/usage/js/analyzer.js +240 -0
- package/src/usage/js/parser.js +21 -0
- package/src/usage/runtime.js +187 -0
- package/src/usage/scope.js +44 -0
- package/src/usage/source-files.js +50 -0
- package/src/usage/usage-index.js +217 -0
- package/src/usage/writer.js +31 -0
- package/src/version-analysis-loader.js +203 -0
- package/src/version-analysis-manifest.js +314 -0
- package/src/version-analysis-writer.js +30 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createDefaultEcosystemVersionAdapterRegistry,
|
|
6
|
+
getEcosystemVersionAdapter
|
|
7
|
+
} from './ecosystem-version-adapter.js';
|
|
8
|
+
import { toPosixPath } from './files.js';
|
|
9
|
+
|
|
10
|
+
const SUPPORTED_NPM_LOCKFILE_VERSIONS = new Set([1, 2, 3]);
|
|
11
|
+
const NON_REGISTRY_DECLARATION =
|
|
12
|
+
/^(?:workspace:|file:|link:|git\+|git:|https?:|ssh:|github:|gitlab:|bitbucket:|\.{1,2}[\\/]|[\\/]|~[\\/])/i;
|
|
13
|
+
|
|
14
|
+
function unresolved(reason) {
|
|
15
|
+
return {
|
|
16
|
+
installedVersion: null,
|
|
17
|
+
installedVersionStatus: 'unresolved',
|
|
18
|
+
installedVersionSource: null,
|
|
19
|
+
installedVersionReason: reason
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolved(version, source) {
|
|
24
|
+
return {
|
|
25
|
+
installedVersion: version,
|
|
26
|
+
installedVersionStatus: 'resolved',
|
|
27
|
+
installedVersionSource: source,
|
|
28
|
+
installedVersionReason: null
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function repositoryPath(...parts) {
|
|
33
|
+
const joined = path.posix.join(...parts.filter((part) => part && part !== '.'));
|
|
34
|
+
return joined || '.';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lockfileRoots(project) {
|
|
38
|
+
if (project.workspace?.role === 'member') return [project.workspace.root];
|
|
39
|
+
return [project.path];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadNpmLockfile(repositoryRoot, lockfileRoot, cache) {
|
|
43
|
+
const portablePath = repositoryPath(lockfileRoot, 'package-lock.json');
|
|
44
|
+
if (!cache.has(portablePath)) {
|
|
45
|
+
cache.set(portablePath, (async () => {
|
|
46
|
+
let bytes;
|
|
47
|
+
try {
|
|
48
|
+
bytes = await readFile(path.resolve(repositoryRoot, portablePath));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === 'ENOENT') return { status: 'missing', path: portablePath };
|
|
51
|
+
return { status: 'invalid', path: portablePath };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let lockfile;
|
|
55
|
+
try {
|
|
56
|
+
lockfile = JSON.parse(bytes.toString('utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return { status: 'invalid', path: portablePath };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lockfileVersion = lockfile?.lockfileVersion;
|
|
62
|
+
if (!Number.isInteger(lockfileVersion) || !SUPPORTED_NPM_LOCKFILE_VERSIONS.has(lockfileVersion)) {
|
|
63
|
+
return { status: 'unsupported', path: portablePath, lockfileVersion };
|
|
64
|
+
}
|
|
65
|
+
if (lockfileVersion === 1) {
|
|
66
|
+
if (!lockfile.dependencies || typeof lockfile.dependencies !== 'object' || Array.isArray(lockfile.dependencies)) {
|
|
67
|
+
return { status: 'invalid', path: portablePath, lockfileVersion };
|
|
68
|
+
}
|
|
69
|
+
} else if (!lockfile.packages || typeof lockfile.packages !== 'object' || Array.isArray(lockfile.packages)) {
|
|
70
|
+
return { status: 'invalid', path: portablePath, lockfileVersion };
|
|
71
|
+
}
|
|
72
|
+
return { status: 'loaded', path: portablePath, lockfileVersion, lockfile };
|
|
73
|
+
})());
|
|
74
|
+
}
|
|
75
|
+
return cache.get(portablePath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function lockfileRelativeProjectPath(project, lockfileRoot) {
|
|
79
|
+
const root = lockfileRoot === '.' ? '' : lockfileRoot;
|
|
80
|
+
const projectPath = project.path === '.' ? '' : project.path;
|
|
81
|
+
return toPosixPath(path.posix.relative(root, projectPath));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function packageEntryCandidates(project, lockfileRoot, dependencyName) {
|
|
85
|
+
const relativeProject = lockfileRelativeProjectPath(project, lockfileRoot);
|
|
86
|
+
return [...new Set([
|
|
87
|
+
repositoryPath(relativeProject, 'node_modules', dependencyName),
|
|
88
|
+
repositoryPath('node_modules', dependencyName)
|
|
89
|
+
])];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ambiguousWorkspaceResolution(lockfile, project, dependencyName, candidates) {
|
|
93
|
+
if (project.workspace?.role !== 'member') return false;
|
|
94
|
+
const expected = new Set(candidates);
|
|
95
|
+
const suffix = `/node_modules/${dependencyName}`;
|
|
96
|
+
const otherMatches = Object.keys(lockfile.packages)
|
|
97
|
+
.filter((entryPath) => !expected.has(entryPath) && entryPath.endsWith(suffix));
|
|
98
|
+
return otherMatches.length > 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function exactInstalledVersion(adapter, value) {
|
|
102
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
103
|
+
const normalized = adapter.normalizeVersion(value);
|
|
104
|
+
return normalized.ok ? normalized.value : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveFromV1(lockfileRecord, project, dependency, adapter, lockfileRoot) {
|
|
108
|
+
if (project.path !== lockfileRoot || project.workspace?.role === 'member') {
|
|
109
|
+
return unresolved('LOCKFILE_UNSUPPORTED');
|
|
110
|
+
}
|
|
111
|
+
const entry = lockfileRecord.lockfile.dependencies[dependency.name];
|
|
112
|
+
if (!entry) return unresolved('PACKAGE_NOT_RESOLVED');
|
|
113
|
+
const version = exactInstalledVersion(adapter, entry.version);
|
|
114
|
+
if (version === null) return unresolved('RESOLVED_VERSION_UNAVAILABLE');
|
|
115
|
+
return resolved(version, {
|
|
116
|
+
type: 'package-lock',
|
|
117
|
+
path: lockfileRecord.path,
|
|
118
|
+
lockfileVersion: lockfileRecord.lockfileVersion,
|
|
119
|
+
packagePath: repositoryPath(lockfileRoot, 'node_modules', dependency.name)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveFromPackages(lockfileRecord, project, dependency, adapter, lockfileRoot) {
|
|
124
|
+
const candidates = packageEntryCandidates(project, lockfileRoot, dependency.name);
|
|
125
|
+
for (const candidate of candidates) {
|
|
126
|
+
const entry = lockfileRecord.lockfile.packages[candidate];
|
|
127
|
+
if (!entry) continue;
|
|
128
|
+
const version = exactInstalledVersion(adapter, entry.version);
|
|
129
|
+
if (version === null) return unresolved('RESOLVED_VERSION_UNAVAILABLE');
|
|
130
|
+
return resolved(version, {
|
|
131
|
+
type: 'package-lock',
|
|
132
|
+
path: lockfileRecord.path,
|
|
133
|
+
lockfileVersion: lockfileRecord.lockfileVersion,
|
|
134
|
+
packagePath: repositoryPath(lockfileRoot, candidate)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (ambiguousWorkspaceResolution(lockfileRecord.lockfile, project, dependency.name, candidates)) {
|
|
138
|
+
return unresolved('WORKSPACE_RESOLUTION_AMBIGUOUS');
|
|
139
|
+
}
|
|
140
|
+
return unresolved('PACKAGE_NOT_RESOLVED');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resolveNpmDependency(repositoryRoot, project, dependency, adapter, cache) {
|
|
144
|
+
if (NON_REGISTRY_DECLARATION.test(dependency.declaredVersion ?? '')) {
|
|
145
|
+
return unresolved('NON_REGISTRY_DEPENDENCY');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const lockfileRoot of lockfileRoots(project)) {
|
|
149
|
+
const lockfileRecord = await loadNpmLockfile(repositoryRoot, lockfileRoot, cache);
|
|
150
|
+
if (lockfileRecord.status === 'missing') continue;
|
|
151
|
+
if (lockfileRecord.status === 'invalid') return unresolved('INVALID_LOCKFILE');
|
|
152
|
+
if (lockfileRecord.status === 'unsupported') return unresolved('LOCKFILE_UNSUPPORTED');
|
|
153
|
+
if (lockfileRecord.lockfileVersion === 1) {
|
|
154
|
+
return resolveFromV1(lockfileRecord, project, dependency, adapter, lockfileRoot);
|
|
155
|
+
}
|
|
156
|
+
return resolveFromPackages(lockfileRecord, project, dependency, adapter, lockfileRoot);
|
|
157
|
+
}
|
|
158
|
+
return unresolved('LOCKFILE_NOT_FOUND');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Attach deterministic installed-version facts after project/workspace
|
|
163
|
+
* ownership has been established. Dependency array identity and ordering are
|
|
164
|
+
* preserved; only additive baseline fields are written.
|
|
165
|
+
*/
|
|
166
|
+
export async function attachInstalledVersionBaselines(repositoryRoot, projects, options = {}) {
|
|
167
|
+
const registry = options.adapterRegistry ?? createDefaultEcosystemVersionAdapterRegistry();
|
|
168
|
+
const npmAdapter = getEcosystemVersionAdapter('node', registry);
|
|
169
|
+
const lockfileCache = new Map();
|
|
170
|
+
const cooperativeScheduler = options.cooperativeScheduler;
|
|
171
|
+
|
|
172
|
+
for (const project of projects) {
|
|
173
|
+
for (const dependency of project.dependencies) {
|
|
174
|
+
try {
|
|
175
|
+
let baseline;
|
|
176
|
+
if (project.ecosystem !== 'node') {
|
|
177
|
+
baseline = unresolved('RESOLVED_VERSION_UNAVAILABLE');
|
|
178
|
+
} else if (project.packageManager?.name && project.packageManager.name !== 'npm') {
|
|
179
|
+
baseline = unresolved('LOCKFILE_UNSUPPORTED');
|
|
180
|
+
} else {
|
|
181
|
+
baseline = await resolveNpmDependency(
|
|
182
|
+
repositoryRoot,
|
|
183
|
+
project,
|
|
184
|
+
dependency,
|
|
185
|
+
npmAdapter,
|
|
186
|
+
lockfileCache
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
Object.assign(dependency, baseline);
|
|
190
|
+
} finally {
|
|
191
|
+
await cooperativeScheduler?.boundary();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return projects;
|
|
196
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { canonicalJson, canonicalJsonBytes } from './canonical-json.js';
|
|
6
|
+
import { DEFAULT_OUTPUT_DIRECTORY } from './constants.js';
|
|
7
|
+
import { compareText } from './portable.js';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_KNOWLEDGE_CACHE_DIRECTORY =
|
|
10
|
+
`${DEFAULT_OUTPUT_DIRECTORY}/cache/knowledge/v1`;
|
|
11
|
+
|
|
12
|
+
const ENVELOPE_VERSION = '1';
|
|
13
|
+
const IDENTITY_FIELDS = [
|
|
14
|
+
'adapter',
|
|
15
|
+
'resourceKind',
|
|
16
|
+
'packageId',
|
|
17
|
+
'resourceVariant',
|
|
18
|
+
'adapterVersion'
|
|
19
|
+
];
|
|
20
|
+
const ENVELOPE_FIELDS = [
|
|
21
|
+
'envelopeVersion',
|
|
22
|
+
'identity',
|
|
23
|
+
'storedAt',
|
|
24
|
+
'expiresAt',
|
|
25
|
+
'bodyDigest',
|
|
26
|
+
'body'
|
|
27
|
+
];
|
|
28
|
+
const ISO_UTC_MILLISECONDS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
29
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
30
|
+
const FORBIDDEN_BODY_KEYS = new Set([
|
|
31
|
+
'authorization',
|
|
32
|
+
'proxyauthorization',
|
|
33
|
+
'cookie',
|
|
34
|
+
'setcookie',
|
|
35
|
+
'etag',
|
|
36
|
+
'lastmodified',
|
|
37
|
+
'headers',
|
|
38
|
+
'cachekey',
|
|
39
|
+
'stack',
|
|
40
|
+
'password',
|
|
41
|
+
'credential',
|
|
42
|
+
'credentials',
|
|
43
|
+
'secret',
|
|
44
|
+
'token',
|
|
45
|
+
'accesstoken',
|
|
46
|
+
'apikey',
|
|
47
|
+
'clientsecret',
|
|
48
|
+
'privatekey'
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const defaultFileSystem = { mkdir, open, readFile, rename, rm };
|
|
52
|
+
|
|
53
|
+
export class KnowledgeCacheError extends Error {
|
|
54
|
+
constructor(code, message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'KnowledgeCacheError';
|
|
57
|
+
this.code = code;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sameFields(value, fields) {
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
63
|
+
const keys = Object.keys(value).sort(compareText);
|
|
64
|
+
return keys.length === fields.length && keys.every((key, index) => key === [...fields].sort(compareText)[index]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isUnsafeIdentityValue(value) {
|
|
68
|
+
return typeof value !== 'string'
|
|
69
|
+
|| value.length === 0
|
|
70
|
+
|| value.trim() !== value
|
|
71
|
+
|| /[\u0000-\u001f\u007f\\\s]/.test(value)
|
|
72
|
+
|| value.startsWith('/')
|
|
73
|
+
|| /^[A-Za-z]:/.test(value)
|
|
74
|
+
|| value.includes('..')
|
|
75
|
+
|| /[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function identityError(message) {
|
|
79
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_IDENTITY', message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create the canonical logical identity used for a single cached resource.
|
|
84
|
+
* The filesystem key is derived internally and is never written to envelopes.
|
|
85
|
+
*/
|
|
86
|
+
export function createCacheIdentity(input) {
|
|
87
|
+
if (!sameFields(input, IDENTITY_FIELDS)) {
|
|
88
|
+
identityError('Cache identity must contain exactly the required fields.');
|
|
89
|
+
}
|
|
90
|
+
for (const field of IDENTITY_FIELDS) {
|
|
91
|
+
if (isUnsafeIdentityValue(input[field])) {
|
|
92
|
+
identityError(`Cache identity field ${field} must be a portable non-empty string.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze(Object.fromEntries(IDENTITY_FIELDS.map((field) => [field, input[field]])));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function identityKey(identity) {
|
|
99
|
+
return createHash('sha256').update(canonicalJson(identity), 'utf8').digest('hex');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bodyDigest(body) {
|
|
103
|
+
return `sha256:${createHash('sha256').update(canonicalJsonBytes(body)).digest('hex')}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizedForbiddenKey(key) {
|
|
107
|
+
return key.toLowerCase().replace(/[-_]/g, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function inspectBodyPrivacy(value, currentPath = '$', ancestors = new Set()) {
|
|
111
|
+
if (typeof value === 'string') {
|
|
112
|
+
if (value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value)) {
|
|
113
|
+
return `${currentPath} contains an absolute path.`;
|
|
114
|
+
}
|
|
115
|
+
if (/[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(value)) {
|
|
116
|
+
return `${currentPath} contains URL credentials.`;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (!value || typeof value !== 'object') return null;
|
|
121
|
+
if (ancestors.has(value)) return null;
|
|
122
|
+
ancestors.add(value);
|
|
123
|
+
try {
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
126
|
+
const result = inspectBodyPrivacy(value[index], `${currentPath}[${index}]`, ancestors);
|
|
127
|
+
if (result) return result;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
for (const key of Object.keys(value)) {
|
|
132
|
+
if (FORBIDDEN_BODY_KEYS.has(normalizedForbiddenKey(key))) {
|
|
133
|
+
return `${currentPath}.${key} is not permitted in a cache body.`;
|
|
134
|
+
}
|
|
135
|
+
const result = inspectBodyPrivacy(value[key], `${currentPath}.${key}`, ancestors);
|
|
136
|
+
if (result) return result;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
} finally {
|
|
140
|
+
ancestors.delete(value);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sanitizeBody(body) {
|
|
145
|
+
let canonical;
|
|
146
|
+
try {
|
|
147
|
+
canonical = canonicalJson(body);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_BODY', 'Cache body must be JSON-compatible.');
|
|
150
|
+
}
|
|
151
|
+
const privacyError = inspectBodyPrivacy(body);
|
|
152
|
+
if (privacyError) {
|
|
153
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_BODY', `Cache body is not portable: ${privacyError}`);
|
|
154
|
+
}
|
|
155
|
+
return JSON.parse(canonical);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validUtcDate(value) {
|
|
159
|
+
if (typeof value !== 'string' || !ISO_UTC_MILLISECONDS.test(value)) return null;
|
|
160
|
+
const date = new Date(value);
|
|
161
|
+
return Number.isNaN(date.getTime()) || date.toISOString() !== value ? null : date;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resultForEnvelope(envelope, now) {
|
|
165
|
+
const expiresAt = new Date(envelope.expiresAt);
|
|
166
|
+
return {
|
|
167
|
+
status: now.getTime() < expiresAt.getTime() ? 'fresh' : 'expired',
|
|
168
|
+
body: envelope.body,
|
|
169
|
+
storedAt: envelope.storedAt,
|
|
170
|
+
expiresAt: envelope.expiresAt,
|
|
171
|
+
bodyDigest: envelope.bodyDigest
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateEnvelope(envelope, expectedIdentity) {
|
|
176
|
+
if (!sameFields(envelope, ENVELOPE_FIELDS)) return { valid: false, reason: 'unexpected-envelope-fields' };
|
|
177
|
+
if (envelope.envelopeVersion !== ENVELOPE_VERSION) return { valid: false, reason: 'unsupported-envelope-version' };
|
|
178
|
+
|
|
179
|
+
let identity;
|
|
180
|
+
try {
|
|
181
|
+
identity = createCacheIdentity(envelope.identity);
|
|
182
|
+
} catch {
|
|
183
|
+
return { valid: false, reason: 'invalid-identity' };
|
|
184
|
+
}
|
|
185
|
+
if (canonicalJson(identity) !== canonicalJson(expectedIdentity)) {
|
|
186
|
+
return { valid: false, reason: 'identity-mismatch' };
|
|
187
|
+
}
|
|
188
|
+
const storedAt = validUtcDate(envelope.storedAt);
|
|
189
|
+
const expiresAt = validUtcDate(envelope.expiresAt);
|
|
190
|
+
if (!storedAt || !expiresAt || expiresAt.getTime() < storedAt.getTime()) {
|
|
191
|
+
return { valid: false, reason: 'invalid-timestamps' };
|
|
192
|
+
}
|
|
193
|
+
if (typeof envelope.bodyDigest !== 'string' || !DIGEST.test(envelope.bodyDigest)) {
|
|
194
|
+
return { valid: false, reason: 'invalid-body-digest' };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const normalizedBody = sanitizeBody(envelope.body);
|
|
198
|
+
if (bodyDigest(normalizedBody) !== envelope.bodyDigest) {
|
|
199
|
+
return { valid: false, reason: 'body-digest-mismatch' };
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
return { valid: false, reason: 'invalid-body' };
|
|
203
|
+
}
|
|
204
|
+
return { valid: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function currentDate(clock) {
|
|
208
|
+
const value = clock();
|
|
209
|
+
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
210
|
+
if (Number.isNaN(date.getTime())) {
|
|
211
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_CLOCK', 'Knowledge Store clock returned an invalid time.');
|
|
212
|
+
}
|
|
213
|
+
return date;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ttlDate(storedAt, ttlMs) {
|
|
217
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 0) {
|
|
218
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_TTL', 'Cache TTL must be a non-negative integer number of milliseconds.');
|
|
219
|
+
}
|
|
220
|
+
const expiresAt = new Date(storedAt.getTime() + ttlMs);
|
|
221
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
222
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_TTL', 'Cache TTL produces an invalid expiry time.');
|
|
223
|
+
}
|
|
224
|
+
return expiresAt;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function operationalError(code, message) {
|
|
228
|
+
return new KnowledgeCacheError(code, message);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Construct the small private filesystem cache used by future registry
|
|
233
|
+
* adapters. Its root and storage layout are intentionally absent from the
|
|
234
|
+
* Knowledge Manifest contract.
|
|
235
|
+
*/
|
|
236
|
+
export function createKnowledgeCache({
|
|
237
|
+
rootDirectory = DEFAULT_KNOWLEDGE_CACHE_DIRECTORY,
|
|
238
|
+
clock = () => new Date(),
|
|
239
|
+
fileSystem = defaultFileSystem
|
|
240
|
+
} = {}) {
|
|
241
|
+
if (typeof rootDirectory !== 'string' || rootDirectory.length === 0) {
|
|
242
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_ROOT', 'Knowledge Store root directory must be a non-empty string.');
|
|
243
|
+
}
|
|
244
|
+
if (typeof clock !== 'function') {
|
|
245
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_CLOCK', 'Knowledge Store clock must be a function.');
|
|
246
|
+
}
|
|
247
|
+
for (const method of ['mkdir', 'open', 'readFile', 'rename', 'rm']) {
|
|
248
|
+
if (typeof fileSystem?.[method] !== 'function') {
|
|
249
|
+
throw new KnowledgeCacheError('KNOWLEDGE_CACHE_INVALID_FILESYSTEM', 'Knowledge Store filesystem adapter is incomplete.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let temporarySequence = 0;
|
|
254
|
+
const entryPath = (identity) => path.join(rootDirectory, `${identityKey(createCacheIdentity(identity))}.json`);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
async read(identity) {
|
|
258
|
+
const expectedIdentity = createCacheIdentity(identity);
|
|
259
|
+
let text;
|
|
260
|
+
try {
|
|
261
|
+
text = await fileSystem.readFile(entryPath(expectedIdentity), 'utf8');
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === 'ENOENT') return { status: 'missing' };
|
|
264
|
+
throw operationalError('KNOWLEDGE_CACHE_READ_FAILED', 'Unable to read Knowledge Store entry.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let envelope;
|
|
268
|
+
try {
|
|
269
|
+
envelope = JSON.parse(text);
|
|
270
|
+
} catch {
|
|
271
|
+
return { status: 'corrupted', reason: 'invalid-json' };
|
|
272
|
+
}
|
|
273
|
+
const validation = validateEnvelope(envelope, expectedIdentity);
|
|
274
|
+
if (!validation.valid) return { status: 'corrupted', reason: validation.reason };
|
|
275
|
+
return resultForEnvelope(envelope, currentDate(clock));
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async write(identity, body, { ttlMs } = {}) {
|
|
279
|
+
const normalizedIdentity = createCacheIdentity(identity);
|
|
280
|
+
const normalizedBody = sanitizeBody(body);
|
|
281
|
+
const storedAtDate = currentDate(clock);
|
|
282
|
+
const expiresAtDate = ttlDate(storedAtDate, ttlMs);
|
|
283
|
+
const envelope = {
|
|
284
|
+
envelopeVersion: ENVELOPE_VERSION,
|
|
285
|
+
identity: normalizedIdentity,
|
|
286
|
+
storedAt: storedAtDate.toISOString(),
|
|
287
|
+
expiresAt: expiresAtDate.toISOString(),
|
|
288
|
+
bodyDigest: bodyDigest(normalizedBody),
|
|
289
|
+
body: normalizedBody
|
|
290
|
+
};
|
|
291
|
+
const finalPath = entryPath(normalizedIdentity);
|
|
292
|
+
const temporaryPath = `${finalPath}.${process.pid}.${temporarySequence += 1}.tmp`;
|
|
293
|
+
const serialized = `${JSON.stringify(envelope, null, 2)}\n`;
|
|
294
|
+
let handle;
|
|
295
|
+
try {
|
|
296
|
+
await fileSystem.mkdir(path.dirname(finalPath), { recursive: true });
|
|
297
|
+
handle = await fileSystem.open(temporaryPath, 'w', 0o600);
|
|
298
|
+
await handle.writeFile(serialized, 'utf8');
|
|
299
|
+
await handle.sync();
|
|
300
|
+
await handle.close();
|
|
301
|
+
handle = undefined;
|
|
302
|
+
await fileSystem.rename(temporaryPath, finalPath);
|
|
303
|
+
} catch {
|
|
304
|
+
try {
|
|
305
|
+
if (handle) await handle.close();
|
|
306
|
+
} catch {
|
|
307
|
+
// Closing is best effort after an already-failed write.
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
await fileSystem.rm(temporaryPath, { force: true });
|
|
311
|
+
} catch {
|
|
312
|
+
// Cleanup is best effort; the final entry remains protected by rename.
|
|
313
|
+
}
|
|
314
|
+
throw operationalError('KNOWLEDGE_CACHE_WRITE_FAILED', 'Unable to write Knowledge Store entry.');
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
status: 'written',
|
|
318
|
+
storedAt: envelope.storedAt,
|
|
319
|
+
expiresAt: envelope.expiresAt,
|
|
320
|
+
bodyDigest: envelope.bodyDigest
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
5
|
+
import addFormats from 'ajv-formats';
|
|
6
|
+
|
|
7
|
+
import { compareText, isSorted } from './portable.js';
|
|
8
|
+
|
|
9
|
+
export const KNOWLEDGE_EVIDENCE_BUNDLE_SCHEMA_VERSION = '1.0.0';
|
|
10
|
+
|
|
11
|
+
const schema = JSON.parse(await readFile(
|
|
12
|
+
new URL('../schemas/knowledge-evidence-bundle.schema.json', import.meta.url),
|
|
13
|
+
'utf8'
|
|
14
|
+
));
|
|
15
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
|
|
16
|
+
addFormats(ajv);
|
|
17
|
+
const validateSchema = ajv.compile(schema);
|
|
18
|
+
|
|
19
|
+
const compareEvidence = (left, right) => compareText(left.id, right.id);
|
|
20
|
+
const compareWarnings = (left, right) => {
|
|
21
|
+
const fields = ['packageId', 'sourceId', 'code', 'message'];
|
|
22
|
+
for (const field of fields) {
|
|
23
|
+
const result = compareText(left[field] ?? '', right[field] ?? '');
|
|
24
|
+
if (result !== 0) return result;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function digestText(value) {
|
|
30
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function duplicateValues(values) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const duplicates = new Set();
|
|
36
|
+
for (const value of values) {
|
|
37
|
+
if (seen.has(value)) duplicates.add(value);
|
|
38
|
+
seen.add(value);
|
|
39
|
+
}
|
|
40
|
+
return [...duplicates].sort(compareText);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addMismatch(errors, field, actual, expected) {
|
|
44
|
+
if (actual !== expected) errors.push(`${field} is ${actual}; expected ${expected}.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate Knowledge Evidence Bundle relationships that JSON Schema cannot
|
|
49
|
+
* express. Cross-artifact source/package checks happen in the VA-02 loader.
|
|
50
|
+
*/
|
|
51
|
+
export function validateKnowledgeEvidenceBundleInvariants(bundle) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
const evidence = bundle.evidence ?? [];
|
|
54
|
+
const warnings = bundle.warnings ?? [];
|
|
55
|
+
|
|
56
|
+
if (!isSorted(evidence, compareEvidence)) errors.push('evidence must be sorted by id.');
|
|
57
|
+
if (!isSorted(warnings, compareWarnings)) {
|
|
58
|
+
errors.push('warnings must be sorted by packageId, sourceId, code, and message.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const duplicate of duplicateValues(evidence.map((item) => item.id))) {
|
|
62
|
+
errors.push(`Duplicate evidence id ${duplicate}.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const packageIds = new Set();
|
|
66
|
+
const sourceIds = new Set();
|
|
67
|
+
for (const item of evidence) {
|
|
68
|
+
packageIds.add(item.packageId);
|
|
69
|
+
sourceIds.add(item.sourceId);
|
|
70
|
+
if (!isSorted(item.releaseVersions, compareText)) {
|
|
71
|
+
errors.push(`Evidence ${item.id} releaseVersions must be sorted lexically.`);
|
|
72
|
+
}
|
|
73
|
+
const actualDigest = digestText(item.content);
|
|
74
|
+
if (item.contentDigest !== actualDigest) {
|
|
75
|
+
errors.push(`Evidence ${item.id} contentDigest is ${item.contentDigest}; expected ${actualDigest}.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
addMismatch(errors, 'summary.evidenceCount', bundle.summary?.evidenceCount, evidence.length);
|
|
80
|
+
addMismatch(errors, 'summary.packageCount', bundle.summary?.packageCount, packageIds.size);
|
|
81
|
+
addMismatch(errors, 'summary.sourceCount', bundle.summary?.sourceCount, sourceIds.size);
|
|
82
|
+
addMismatch(errors, 'summary.warningCount', bundle.summary?.warningCount, warnings.length);
|
|
83
|
+
|
|
84
|
+
return errors.sort(compareText);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateKnowledgeEvidenceBundle(bundle) {
|
|
88
|
+
if (bundle?.schemaVersion !== KNOWLEDGE_EVIDENCE_BUNDLE_SCHEMA_VERSION) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Knowledge Evidence Bundle schema validation failed: unsupported schema version; expected ${KNOWLEDGE_EVIDENCE_BUNDLE_SCHEMA_VERSION}.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!validateSchema(bundle)) {
|
|
94
|
+
throw new Error(`Knowledge Evidence Bundle schema validation failed: ${ajv.errorsText(validateSchema.errors, { separator: '; ' })}`);
|
|
95
|
+
}
|
|
96
|
+
const invariantErrors = validateKnowledgeEvidenceBundleInvariants(bundle);
|
|
97
|
+
if (invariantErrors.length > 0) {
|
|
98
|
+
throw new Error(`Knowledge Evidence Bundle runtime invariants failed: ${invariantErrors.join(' ')}`);
|
|
99
|
+
}
|
|
100
|
+
return bundle;
|
|
101
|
+
}
|