@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.
Files changed (252) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/bin/depverdict.js +7 -0
  4. package/bin/upgradelens.js +7 -0
  5. package/docs/GR-01-Semantic-Grounding-Failure-Analysis.md +309 -0
  6. package/docs/GR-02-Versioned-Action-Evaluation-Criteria.md +187 -0
  7. package/docs/GR-03-Extractive-Contract-Safety-Experiment.md +242 -0
  8. package/docs/GR-04-Versioned-Production-Extractive-Contract.md +154 -0
  9. package/docs/IA-01-Repository-Usage-Discovery.md +122 -0
  10. package/docs/IA-02-Repository-Impact-Analysis.md +118 -0
  11. package/docs/IA-03-Repository-Impact-Evidence.md +160 -0
  12. package/docs/IA-04-CLI-Orchestration.md +235 -0
  13. package/docs/IA-05-Real-Provider-Validation.md +235 -0
  14. package/docs/IA-05-VinGrade-Validation.md +339 -0
  15. package/docs/MVP-01.md +48 -0
  16. package/docs/MVP-02-Architecture.md +536 -0
  17. package/docs/MVP-02-CLI-HTTP-Runtime.md +50 -0
  18. package/docs/MVP-02-HTTP-Lifecycle.md +37 -0
  19. package/docs/MVP-02-Knowledge-Manifest-Generation.md +75 -0
  20. package/docs/MVP-02-Knowledge-Manifest.md +279 -0
  21. package/docs/MVP-02-Knowledge-Research-Orchestration.md +62 -0
  22. package/docs/MVP-02-Knowledge-Store.md +87 -0
  23. package/docs/MVP-02-PyPI-Registry-Adapter.md +97 -0
  24. package/docs/MVP-02-Research-Planning.md +196 -0
  25. package/docs/MVP-02-Source-Provenance.md +81 -0
  26. package/docs/MVP-02-npm-Registry-Adapter.md +99 -0
  27. package/docs/RR-01-End-to-End-and-Real-Provider-Validation.md +393 -0
  28. package/docs/RR-01-RERUN-Extractive-Contract-Validation.md +566 -0
  29. package/docs/RR-02-Full-Product-Workflow-and-Developer-CLI-UX-Review.md +35 -0
  30. package/docs/RR02-FIX-01-Persistent-Qualification-Resolution.md +32 -0
  31. package/docs/RR02-FIX-02-Stage-aware-CLI-Progress-and-Heartbeat.md +28 -0
  32. package/docs/RR02-FIX-03-npm-Capture-Evidence-Exclusion.md +33 -0
  33. package/docs/RR02-FIX-03A-Complete-Package-Exclusion-and-Evidence-Commit.md +223 -0
  34. package/docs/RR02-FIX-04-Event-loop-safe-Heartbeat.md +393 -0
  35. package/docs/RR02-FIX-05-Materialize-Persisted-Qualification.md +207 -0
  36. package/docs/RR02-RERUN-CLI-Qualification-Progress-UX-and-Package-Validation.md +342 -0
  37. package/docs/VinGrade-MVP-02-Validation.md +480 -0
  38. package/docs/VinGrade-RC02-Live-Validation.md +273 -0
  39. package/docs/ai-capability-discovery.md +573 -0
  40. package/docs/ai-engineering-production-readiness.md +390 -0
  41. package/docs/ai-engineering-review.md +251 -0
  42. package/docs/ai-runtime-governance-discovery.md +754 -0
  43. package/docs/architecture-overview.md +78 -0
  44. package/docs/cli-progress.md +111 -0
  45. package/docs/decisions/diff-01-brand-distribution-identity.md +450 -0
  46. package/docs/decisions/diff-02-identity-compatibility-contract.md +274 -0
  47. package/docs/decisions/diff-03-repository-docs-community-migration.md +165 -0
  48. package/docs/decisions/diff-04-release-evidence-gap-acceptance.md +110 -0
  49. package/docs/discovery/mvp-05-ai-migration-planning-discovery.md +350 -0
  50. package/docs/gateway-runtime-discovery.md +614 -0
  51. package/docs/live-ai-validation.md +299 -0
  52. package/docs/migration-planning-qualification-resolution.md +99 -0
  53. package/docs/migrations/upgradelens-to-depverdict.md +97 -0
  54. package/docs/mp-r03-deterministic-upgrade-decision-architecture.md +135 -0
  55. package/docs/mp-r04-evidence-bounded-migration-handoff-architecture.md +116 -0
  56. package/docs/mp-r05-product-completion-and-decision-first-cli-architecture.md +162 -0
  57. package/docs/mvp-05-deterministic-context-runtime.md +127 -0
  58. package/docs/mvp-05-migration-checklist-contract.md +166 -0
  59. package/docs/mvp-05-migration-checklist-orchestration.md +105 -0
  60. package/docs/mvp-05-migration-evaluation-and-qualification.md +135 -0
  61. package/docs/mvp-05-provider-neutral-generator.md +79 -0
  62. package/docs/ollama-local-smoke-validation.md +156 -0
  63. package/docs/openai-compatible-runtime-discovery.md +789 -0
  64. package/docs/openrouter-one-dependency-validation.md +205 -0
  65. package/docs/oss-02-package-guard-hardening-architecture.md +131 -0
  66. package/docs/oss-04-public-ci-package-metadata-architecture.md +142 -0
  67. package/docs/package-content-policy.md +98 -0
  68. package/docs/releases/v0.5.0-technical-preview.md +114 -0
  69. package/docs/releases/v0.6.0-alpha.1-depverdict-preview.md +101 -0
  70. package/docs/reviews/diff-02-identity-contract-compatibility.md +413 -0
  71. package/docs/reviews/diff-03-repository-docs-community-migration.md +364 -0
  72. package/docs/reviews/diff-04-depverdict-distribution-identity-readiness-rereview.md +453 -0
  73. package/docs/reviews/diff-04-fix-post-rename-identity-release-remediation.md +310 -0
  74. package/docs/reviews/diff-05-final-preview-distribution-qualification.md +561 -0
  75. package/docs/reviews/mvp-05-final-product-value-workflow-rereview.md +471 -0
  76. package/docs/reviews/mvp-05-product-workflow-review.md +605 -0
  77. package/docs/reviews/oss-01-duplicate-artifact-investigation-cleanup.md +411 -0
  78. package/docs/reviews/oss-02-package-guard-hardening.md +303 -0
  79. package/docs/reviews/oss-03-community-scaffolding.md +378 -0
  80. package/docs/reviews/oss-04-public-ci-package-metadata.md +439 -0
  81. package/docs/reviews/oss-05-technical-preview-qualification.md +482 -0
  82. package/docs/reviews/upgradelens-vs-upgradedepdetective-source-comparison.md +355 -0
  83. package/docs/reviews/v0.5.0-pre-release-smoke.md +263 -0
  84. package/docs/reviews/v0.5.0-version-bump-release-verification.md +306 -0
  85. package/docs/runtime-contract-discovery.md +521 -0
  86. package/docs/structured-output-compatibility-report.md +100 -0
  87. package/docs/ts-fix-01-exact-duplicate-occurrence-target-selection-architecture.md +111 -0
  88. package/docs/version-analysis-architecture.md +827 -0
  89. package/eval/README.md +86 -0
  90. package/eval/datasets/generic/declared-constraint.json +59 -0
  91. package/eval/datasets/generic/evidence-conflict.json +73 -0
  92. package/eval/datasets/generic/major-breaking-release.json +66 -0
  93. package/eval/datasets/generic/missing-evidence.json +46 -0
  94. package/eval/datasets/generic/patch-release-low.json +59 -0
  95. package/eval/datasets/node/axios-patch-low.json +59 -0
  96. package/eval/datasets/node/react-major-breaking.json +66 -0
  97. package/eval/datasets/node/react-minor-compatibility.json +66 -0
  98. package/eval/datasets/python/fastapi-deprecation.json +66 -0
  99. package/eval/datasets/python/pydantic-major-breaking.json +66 -0
  100. package/eval/migration-planning/golden-dataset-v2.json +306 -0
  101. package/eval/migration-planning/golden-dataset.json +214 -0
  102. package/eval/schemas/expected-result.schema.json +88 -0
  103. package/eval/schemas/golden-case.schema.json +181 -0
  104. package/package.json +57 -0
  105. package/schemas/ai-scorecard.schema.json +132 -0
  106. package/schemas/benchmark-report.schema.json +189 -0
  107. package/schemas/benchmark.schema.json +55 -0
  108. package/schemas/capability-profile.schema.json +44 -0
  109. package/schemas/conformance-report.schema.json +150 -0
  110. package/schemas/deployment-profile.schema.json +64 -0
  111. package/schemas/evaluation-report.schema.json +158 -0
  112. package/schemas/knowledge-evidence-bundle.schema.json +150 -0
  113. package/schemas/knowledge-manifest.schema.json +548 -0
  114. package/schemas/metrics.schema.json +178 -0
  115. package/schemas/migration-checklist-extractive-candidate.schema.json +42 -0
  116. package/schemas/migration-checklist.schema.json +706 -0
  117. package/schemas/migration-evaluation-dataset-v2.schema.json +208 -0
  118. package/schemas/migration-evaluation-dataset.schema.json +204 -0
  119. package/schemas/migration-planning-qualification-record.schema.json +234 -0
  120. package/schemas/project-manifest.schema.json +308 -0
  121. package/schemas/qualification-record.schema.json +56 -0
  122. package/schemas/repository-impact-evidence.schema.json +232 -0
  123. package/schemas/repository-impact.schema.json +202 -0
  124. package/schemas/upgrade-decision.schema.json +273 -0
  125. package/schemas/usage-index.schema.json +179 -0
  126. package/schemas/version-analysis.schema.json +449 -0
  127. package/src/ai-runtime-debug.js +325 -0
  128. package/src/ai-runtime-error.js +42 -0
  129. package/src/ai-runtime.js +174 -0
  130. package/src/ai-scorecard.js +204 -0
  131. package/src/ai-version-analysis.js +484 -0
  132. package/src/artifact-root-compatibility.js +91 -0
  133. package/src/benchmark-report.js +111 -0
  134. package/src/benchmark-runner.js +191 -0
  135. package/src/canonical-json.js +69 -0
  136. package/src/cli.js +1299 -0
  137. package/src/conformance-report.js +158 -0
  138. package/src/conformance-runner.js +253 -0
  139. package/src/constants.js +73 -0
  140. package/src/cooperative-scheduler.js +79 -0
  141. package/src/dependencies.js +44 -0
  142. package/src/dependency-ai-context.js +625 -0
  143. package/src/detectors.js +253 -0
  144. package/src/discovery.js +234 -0
  145. package/src/ecosystem-version-adapter.js +294 -0
  146. package/src/environment-compatibility.js +77 -0
  147. package/src/evaluation-comparator.js +158 -0
  148. package/src/evaluation-report.js +76 -0
  149. package/src/evaluation-runner.js +248 -0
  150. package/src/evidence-source-adapter.js +472 -0
  151. package/src/files.js +89 -0
  152. package/src/governance-diagnostics.js +64 -0
  153. package/src/governance-loader.js +63 -0
  154. package/src/governance-metadata.js +346 -0
  155. package/src/governance-validator.js +360 -0
  156. package/src/http/bounded-fetch.js +278 -0
  157. package/src/http/cli-http-runtime.js +44 -0
  158. package/src/impact/input-loader.js +157 -0
  159. package/src/impact/matcher.js +40 -0
  160. package/src/impact/repository-impact.js +199 -0
  161. package/src/impact/runtime.js +24 -0
  162. package/src/impact/status.js +62 -0
  163. package/src/impact/writer.js +30 -0
  164. package/src/impact-evidence/input-loader.js +202 -0
  165. package/src/impact-evidence/repository-impact-evidence.js +234 -0
  166. package/src/impact-evidence/runtime.js +16 -0
  167. package/src/impact-evidence/writer.js +30 -0
  168. package/src/index.js +563 -0
  169. package/src/installed-version-baseline.js +196 -0
  170. package/src/knowledge-cache.js +324 -0
  171. package/src/knowledge-evidence-bundle.js +101 -0
  172. package/src/knowledge-evidence-producer.js +233 -0
  173. package/src/knowledge-manifest-builder.js +188 -0
  174. package/src/knowledge-manifest-writer.js +32 -0
  175. package/src/knowledge-manifest.js +255 -0
  176. package/src/knowledge-research.js +615 -0
  177. package/src/metrics-engine.js +205 -0
  178. package/src/migration-checklist/ai-candidate.js +320 -0
  179. package/src/migration-checklist/assembler.js +37 -0
  180. package/src/migration-checklist/context-runtime.js +828 -0
  181. package/src/migration-checklist/evaluation/action-criteria.js +244 -0
  182. package/src/migration-checklist/evaluation/comparator-v2.js +332 -0
  183. package/src/migration-checklist/evaluation/comparator.js +279 -0
  184. package/src/migration-checklist/evaluation/dataset-v2.js +227 -0
  185. package/src/migration-checklist/evaluation/dataset.js +336 -0
  186. package/src/migration-checklist/evaluation/extractive-fixtures-v2.js +148 -0
  187. package/src/migration-checklist/evaluation/metrics-v2.js +226 -0
  188. package/src/migration-checklist/evaluation/metrics.js +158 -0
  189. package/src/migration-checklist/evaluation/qualification-v2.js +321 -0
  190. package/src/migration-checklist/evaluation/qualification.js +239 -0
  191. package/src/migration-checklist/evaluation/runner-v2.js +294 -0
  192. package/src/migration-checklist/evaluation/runner.js +194 -0
  193. package/src/migration-checklist/evaluation/scorecard-v2.js +106 -0
  194. package/src/migration-checklist/evaluation/scorecard.js +86 -0
  195. package/src/migration-checklist/extractive-candidate.js +166 -0
  196. package/src/migration-checklist/extractive-prompt.js +62 -0
  197. package/src/migration-checklist/generator.js +702 -0
  198. package/src/migration-checklist/grounding-policy.js +117 -0
  199. package/src/migration-checklist/input-loader.js +613 -0
  200. package/src/migration-checklist/migration-checklist.js +635 -0
  201. package/src/migration-checklist/presentation.js +292 -0
  202. package/src/migration-checklist/progress.js +96 -0
  203. package/src/migration-checklist/prompt.js +83 -0
  204. package/src/migration-checklist/qualification-guard.js +462 -0
  205. package/src/migration-checklist/qualification-resolution.js +122 -0
  206. package/src/migration-checklist/qualification-store.js +225 -0
  207. package/src/migration-checklist/runtime.js +205 -0
  208. package/src/migration-checklist/verification.js +134 -0
  209. package/src/migration-checklist/writer.js +35 -0
  210. package/src/openai-compatible-provider.js +451 -0
  211. package/src/orchestration/failure-log.js +32 -0
  212. package/src/orchestration/pipeline.js +200 -0
  213. package/src/orchestration/progress-events.js +337 -0
  214. package/src/orchestration/progress-reporter.js +131 -0
  215. package/src/orchestration/text-writer.js +22 -0
  216. package/src/portable.js +13 -0
  217. package/src/product-completion.js +249 -0
  218. package/src/project-manifest-input.js +90 -0
  219. package/src/project-manifest.js +141 -0
  220. package/src/python-requirements.js +137 -0
  221. package/src/registry/npm-packument.js +256 -0
  222. package/src/registry/npm-registry-adapter.js +262 -0
  223. package/src/registry/pypi-project.js +300 -0
  224. package/src/registry/pypi-registry-adapter.js +235 -0
  225. package/src/registry/sanitize-registry-body.js +51 -0
  226. package/src/renderers/console.js +160 -0
  227. package/src/renderers/impact-presentation.js +278 -0
  228. package/src/renderers/markdown.js +172 -0
  229. package/src/research-plan.js +455 -0
  230. package/src/runtime-conformance.js +275 -0
  231. package/src/source-provenance.js +393 -0
  232. package/src/source-url.js +62 -0
  233. package/src/structured-output-schema.js +66 -0
  234. package/src/target-selector.js +306 -0
  235. package/src/upgrade-decision/input-loader.js +107 -0
  236. package/src/upgrade-decision/presentation.js +43 -0
  237. package/src/upgrade-decision/runtime.js +21 -0
  238. package/src/upgrade-decision/upgrade-decision.js +626 -0
  239. package/src/upgrade-decision/writer.js +30 -0
  240. package/src/usage/analyzer-registry.js +63 -0
  241. package/src/usage/coverage.js +116 -0
  242. package/src/usage/input-loader.js +139 -0
  243. package/src/usage/js/analyzer.js +240 -0
  244. package/src/usage/js/parser.js +21 -0
  245. package/src/usage/runtime.js +187 -0
  246. package/src/usage/scope.js +44 -0
  247. package/src/usage/source-files.js +50 -0
  248. package/src/usage/usage-index.js +217 -0
  249. package/src/usage/writer.js +31 -0
  250. package/src/version-analysis-loader.js +203 -0
  251. package/src/version-analysis-manifest.js +314 -0
  252. package/src/version-analysis-writer.js +30 -0
@@ -0,0 +1,300 @@
1
+ import { normalizePythonPackageName } from '../python-requirements.js';
2
+ import { compareText } from '../portable.js';
3
+
4
+ const PYPI_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ const PROJECT_URL_ROLES = {
6
+ documentationUrl: ['documentation', 'docs'],
7
+ repositoryUrl: ['source', 'repository', 'code'],
8
+ issueUrl: ['issues', 'tracker', 'bug tracker'],
9
+ homepageUrl: ['homepage', 'home']
10
+ };
11
+ const SOURCE_CANDIDATE_ROLES = {
12
+ changelog: ['changelog', 'changes'],
13
+ releaseNotes: ['release notes'],
14
+ releases: ['releases'],
15
+ migrationGuide: ['migration', 'migration guide', 'upgrade guide']
16
+ };
17
+
18
+ export class PypiProjectError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'PypiProjectError';
22
+ this.code = 'PYPI_PROJECT_INVALID';
23
+ }
24
+ }
25
+
26
+ function plainObject(value) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
28
+ const prototype = Object.getPrototypeOf(value);
29
+ return prototype === Object.prototype || prototype === null;
30
+ }
31
+
32
+ function nullableString(value) {
33
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
34
+ }
35
+
36
+ function normalizedHttpsUrl(value) {
37
+ if (typeof value !== 'string' || value.trim() === '') return null;
38
+ try {
39
+ const url = new URL(value.trim());
40
+ if (url.protocol !== 'https:' || url.username || url.password) return null;
41
+ url.search = '';
42
+ url.hash = '';
43
+ if (url.pathname === '/') return `${url.protocol}//${url.host}`;
44
+ return url.toString();
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function repositoryUrl(value) {
51
+ const raw = typeof value === 'string' ? value : plainObject(value) ? value.url : null;
52
+ if (typeof raw !== 'string') return null;
53
+ const normalized = normalizedHttpsUrl(raw.startsWith('git+') ? raw.slice(4) : raw);
54
+ if (!normalized) return null;
55
+ const url = new URL(normalized);
56
+ if (url.hostname.toLowerCase() === 'github.com') {
57
+ url.pathname = url.pathname.replace(/\.git$/i, '');
58
+ return url.pathname === '/' ? `${url.protocol}//${url.host}` : url.toString();
59
+ }
60
+ return normalized;
61
+ }
62
+
63
+ function normalizedLabel(value) {
64
+ return typeof value === 'string'
65
+ ? value.trim().toLowerCase().replace(/\s+/g, ' ')
66
+ : '';
67
+ }
68
+
69
+ function classifiedProjectUrls(projectUrls) {
70
+ const entries = plainObject(projectUrls)
71
+ ? Object.entries(projectUrls)
72
+ .map(([label, url]) => ({ label: normalizedLabel(label), url }))
73
+ .sort((left, right) => compareText(left.label, right.label) || compareText(String(left.url), String(right.url)))
74
+ : [];
75
+ const result = {};
76
+ for (const [field, labels] of Object.entries(PROJECT_URL_ROLES)) {
77
+ for (const label of labels) {
78
+ const match = entries.find((entry) => entry.label === label);
79
+ if (match) {
80
+ const normalized = field === 'repositoryUrl'
81
+ ? repositoryUrl(match.url)
82
+ : normalizedHttpsUrl(match.url);
83
+ if (normalized) {
84
+ result[field] = normalized;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function sourceCandidates(projectUrls) {
94
+ const entries = plainObject(projectUrls)
95
+ ? Object.entries(projectUrls)
96
+ .map(([label, url]) => ({ label: normalizedLabel(label), originalLabel: String(label), url }))
97
+ .sort((left, right) => compareText(left.label, right.label) || compareText(left.originalLabel, right.originalLabel))
98
+ : [];
99
+ return Object.entries(SOURCE_CANDIDATE_ROLES).flatMap(([role, labels]) => entries
100
+ .filter((entry) => labels.includes(entry.label))
101
+ .flatMap((entry) => {
102
+ const url = normalizedHttpsUrl(entry.url);
103
+ return url ? [{
104
+ role,
105
+ url,
106
+ discoveredFromField: `info.project_urls.${entry.originalLabel}`
107
+ }] : [];
108
+ }))
109
+ .sort((left, right) => compareText(left.role, right.role)
110
+ || compareText(left.url, right.url)
111
+ || compareText(left.discoveredFromField, right.discoveredFromField));
112
+ }
113
+
114
+ function normalizedDate(value) {
115
+ if (typeof value !== 'string') return null;
116
+ const date = new Date(value);
117
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
118
+ }
119
+
120
+ function filesFor(value) {
121
+ return Array.isArray(value) ? value.filter(plainObject) : [];
122
+ }
123
+
124
+ function earliestUpload(files) {
125
+ return files
126
+ .flatMap((file) => [normalizedDate(file.upload_time_iso_8601), normalizedDate(file.upload_time)])
127
+ .filter(Boolean)
128
+ .sort(compareText)[0] ?? null;
129
+ }
130
+
131
+ function yankedState(files) {
132
+ if (files.some((file) => file.yanked === false)) return false;
133
+ return files.length > 0 && files.every((file) => file.yanked === true) ? true : null;
134
+ }
135
+
136
+ function sortedStrings(values) {
137
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))]
138
+ .sort(compareText);
139
+ }
140
+
141
+ function projectStatus(classifiers) {
142
+ if (!Array.isArray(classifiers)) return null;
143
+ return classifiers
144
+ .filter((item) => typeof item === 'string' && item.startsWith('Development Status :: '))
145
+ .sort(compareText)[0] ?? null;
146
+ }
147
+
148
+ function pypiProjectUrl(base, name, version = null) {
149
+ const suffix = version === null ? '' : `${encodeURIComponent(version)}/`;
150
+ return `${base}/project/${encodeURIComponent(name)}/${suffix}`;
151
+ }
152
+
153
+ export function normalizePypiRegistryBaseUrl(value) {
154
+ if (typeof value !== 'string' || value.trim() === '') {
155
+ throw new PypiProjectError('PyPI registry base URL must be a non-empty HTTPS URL.');
156
+ }
157
+ let url;
158
+ try {
159
+ url = new URL(value);
160
+ } catch {
161
+ throw new PypiProjectError('PyPI registry base URL must be a valid HTTPS URL.');
162
+ }
163
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
164
+ throw new PypiProjectError('PyPI registry base URL must be HTTPS and must not contain credentials or query data.');
165
+ }
166
+ url.pathname = url.pathname.replace(/\/$/, '');
167
+ return url.toString().replace(/\/$/, '');
168
+ }
169
+
170
+ export function validatePypiResearchPackage(researchPackage) {
171
+ if (!plainObject(researchPackage)) throw new PypiProjectError('PyPI research package must be an object.');
172
+ const normalizedName = normalizePythonPackageName(researchPackage.normalizedName ?? '');
173
+ if (!PYPI_NAME.test(normalizedName)
174
+ || researchPackage.id !== `pypi:${normalizedName}`
175
+ || researchPackage.registry !== 'pypi'
176
+ || researchPackage.ecosystem !== 'python') {
177
+ throw new PypiProjectError('Research package is not a valid PyPI registry identity.');
178
+ }
179
+ return {
180
+ id: researchPackage.id,
181
+ registry: 'pypi',
182
+ ecosystem: 'python',
183
+ normalizedName,
184
+ observedDeclaredNames: sortedStrings(researchPackage.observedDeclaredNames ?? [normalizedName]),
185
+ occurrences: structuredClone(researchPackage.occurrences ?? [])
186
+ };
187
+ }
188
+
189
+ export function pypiRegistryUrls(registryBaseUrl, normalizedName) {
190
+ const base = normalizePypiRegistryBaseUrl(registryBaseUrl);
191
+ return {
192
+ registryBaseUrl: base,
193
+ packageUrl: pypiProjectUrl(base, normalizedName),
194
+ apiUrl: `${base}/pypi/${encodeURIComponent(normalizedName)}/json`
195
+ };
196
+ }
197
+
198
+ /** Validate the stable subset of PyPI project JSON used by this MVP. */
199
+ export function validatePypiProject(project, requestedNormalizedName) {
200
+ if (!plainObject(project) || !plainObject(project.info)
201
+ || typeof project.info.name !== 'string'
202
+ || normalizePythonPackageName(project.info.name) !== requestedNormalizedName) {
203
+ throw new PypiProjectError('PyPI project metadata has an unsupported shape.');
204
+ }
205
+ if (project.releases !== undefined && !plainObject(project.releases)) {
206
+ throw new PypiProjectError('PyPI project releases must be an object when present.');
207
+ }
208
+ if (project.urls !== undefined && !Array.isArray(project.urls)) {
209
+ throw new PypiProjectError('PyPI project URLs must be an array when present.');
210
+ }
211
+ return project;
212
+ }
213
+
214
+ function metadata(info) {
215
+ const classified = classifiedProjectUrls(info.project_urls);
216
+ return {
217
+ description: nullableString(info.summary) ?? nullableString(info.description),
218
+ license: nullableString(info.license_expression) ?? nullableString(info.license),
219
+ homepageUrl: classified.homepageUrl ?? normalizedHttpsUrl(info.home_page),
220
+ documentationUrl: classified.documentationUrl ?? null,
221
+ repositoryUrl: classified.repositoryUrl ?? null,
222
+ issueUrl: classified.issueUrl ?? null,
223
+ deprecationMessage: null,
224
+ projectStatus: projectStatus(info.classifiers)
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Translate validated PyPI project JSON into package and registry-source
230
+ * records compatible with later Knowledge Manifest assembly.
231
+ */
232
+ export function normalizePypiProject(researchPackage, project, { snapshot, registryBaseUrl }) {
233
+ const input = validatePypiResearchPackage(researchPackage);
234
+ validatePypiProject(project, input.normalizedName);
235
+ const urls = pypiRegistryUrls(registryBaseUrl, input.normalizedName);
236
+ const sourceId = `${input.id}:registry`;
237
+ const releases = plainObject(project.releases) ? project.releases : {};
238
+ const latestVersion = nullableString(project.info.version);
239
+ const latestFiles = latestVersion && Object.hasOwn(releases, latestVersion)
240
+ ? filesFor(releases[latestVersion])
241
+ : latestVersion ? filesFor(project.urls) : [];
242
+ const releaseIndex = Object.keys(releases).sort(compareText).map((version) => {
243
+ const files = filesFor(releases[version]);
244
+ return {
245
+ version,
246
+ publishedAt: earliestUpload(files),
247
+ url: pypiProjectUrl(urls.registryBaseUrl, input.normalizedName, version),
248
+ prerelease: null,
249
+ yanked: yankedState(files),
250
+ deprecated: null,
251
+ sourceIds: [sourceId]
252
+ };
253
+ });
254
+ const latest = latestVersion === null ? null : {
255
+ version: latestVersion,
256
+ selection: 'project-info-version',
257
+ publishedAt: earliestUpload(latestFiles),
258
+ releaseUrl: pypiProjectUrl(urls.registryBaseUrl, input.normalizedName, latestVersion),
259
+ prerelease: null,
260
+ yanked: yankedState(latestFiles),
261
+ deprecated: null,
262
+ sourceId
263
+ };
264
+
265
+ return {
266
+ package: {
267
+ id: input.id,
268
+ ecosystem: 'python',
269
+ status: latest ? 'resolved' : 'partial',
270
+ identity: {
271
+ observedDeclaredNames: input.observedDeclaredNames,
272
+ normalizedName: input.normalizedName,
273
+ registry: 'pypi',
274
+ ...urls
275
+ },
276
+ occurrences: input.occurrences,
277
+ metadata: metadata(project.info),
278
+ latest,
279
+ releaseIndex,
280
+ sourceIds: [sourceId],
281
+ warningCodes: []
282
+ },
283
+ source: {
284
+ id: sourceId,
285
+ kind: 'registry',
286
+ authority: 'registryAuthoritative',
287
+ trust: 'publisher',
288
+ url: urls.packageUrl,
289
+ apiUrl: urls.apiUrl,
290
+ status: 'available',
291
+ supports: ['identity', 'latest', 'metadata', 'releases'],
292
+ discoveredFrom: null,
293
+ trustEvidenceSourceIds: [],
294
+ snapshot
295
+ },
296
+ // Internal only: the public metadata contract intentionally has no
297
+ // changelog/release-note fields. MVP-02-06 resolves these candidates.
298
+ sourceCandidates: sourceCandidates(project.info.project_urls)
299
+ };
300
+ }
@@ -0,0 +1,235 @@
1
+ import {
2
+ fetchRegistryJson,
3
+ BoundedFetchError,
4
+ validateRegistryResponseLimit
5
+ } from '../http/bounded-fetch.js';
6
+ import { USER_AGENT } from '../constants.js';
7
+ import { createCacheIdentity, createKnowledgeCache } from '../knowledge-cache.js';
8
+ import {
9
+ normalizePypiProject,
10
+ normalizePypiRegistryBaseUrl,
11
+ pypiRegistryUrls,
12
+ validatePypiProject,
13
+ validatePypiResearchPackage
14
+ } from './pypi-project.js';
15
+ import { sanitizeRegistryBodyForCache } from './sanitize-registry-body.js';
16
+
17
+ export const DEFAULT_PYPI_REGISTRY_BASE_URL = 'https://pypi.org';
18
+ export const DEFAULT_PYPI_INDEX_BASE_URL = 'https://pypi.org/simple';
19
+ export const DEFAULT_PYPI_METADATA_TTL_MS = 24 * 60 * 60 * 1_000;
20
+ export const DEFAULT_PYPI_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
21
+
22
+ function cacheIdentityFor(input) {
23
+ return createCacheIdentity({
24
+ adapter: 'pypi',
25
+ resourceKind: 'registry-package',
26
+ packageId: input.id,
27
+ resourceVariant: 'project-json',
28
+ adapterVersion: '1'
29
+ });
30
+ }
31
+
32
+ function snapshot(bodyDigest, retrievedAt) {
33
+ return {
34
+ contentDigest: bodyDigest,
35
+ mediaType: 'application/json',
36
+ retrievedAt,
37
+ freshness: 'fresh'
38
+ };
39
+ }
40
+
41
+ function cacheOutcome(readResult) {
42
+ if (readResult.status === 'missing') return { outcome: 'miss' };
43
+ if (readResult.status === 'expired') return { outcome: 'expired' };
44
+ if (readResult.status === 'corrupted') return { outcome: 'corrupted', reason: readResult.reason };
45
+ return { outcome: 'hit' };
46
+ }
47
+
48
+ function unavailableResult(input, registryBaseUrl, cache, {
49
+ status = 'unavailable', warningCode, message, retryable, errorCode
50
+ }) {
51
+ const urls = pypiRegistryUrls(registryBaseUrl, input.normalizedName);
52
+ const sourceId = `${input.id}:registry`;
53
+ const warning = { code: warningCode, packageId: input.id, sourceId, message, retryable };
54
+ return {
55
+ package: {
56
+ id: input.id,
57
+ ecosystem: 'python',
58
+ status,
59
+ identity: {
60
+ observedDeclaredNames: input.observedDeclaredNames,
61
+ normalizedName: input.normalizedName,
62
+ registry: 'pypi',
63
+ ...urls
64
+ },
65
+ occurrences: input.occurrences,
66
+ metadata: {},
67
+ latest: null,
68
+ releaseIndex: [],
69
+ sourceIds: [sourceId],
70
+ warningCodes: [warningCode]
71
+ },
72
+ source: {
73
+ id: sourceId,
74
+ kind: 'registry',
75
+ authority: 'registryAuthoritative',
76
+ trust: 'publisher',
77
+ url: urls.packageUrl,
78
+ apiUrl: urls.apiUrl,
79
+ status: status === 'notFound' ? 'notFound' : 'unavailable',
80
+ supports: ['identity'],
81
+ discoveredFrom: null,
82
+ trustEvidenceSourceIds: [],
83
+ snapshot: null
84
+ },
85
+ cache,
86
+ warnings: [warning],
87
+ errorCode
88
+ };
89
+ }
90
+
91
+ function normalizedResult(researchPackage, body, registryBaseUrl, cache, cacheRecord) {
92
+ return {
93
+ ...normalizePypiProject(researchPackage, body, {
94
+ registryBaseUrl,
95
+ snapshot: snapshot(cacheRecord.bodyDigest, cacheRecord.storedAt)
96
+ }),
97
+ cache,
98
+ warnings: [],
99
+ errorCode: null
100
+ };
101
+ }
102
+
103
+ /** Private adapter for public PyPI project JSON, independent of Python installers. */
104
+ export function createPypiRegistryAdapter({
105
+ registryBaseUrl = DEFAULT_PYPI_REGISTRY_BASE_URL,
106
+ indexBaseUrl = DEFAULT_PYPI_INDEX_BASE_URL,
107
+ fetch: fetchImplementation = globalThis.fetch,
108
+ cache,
109
+ clock = () => new Date(),
110
+ timeoutMs = 10_000,
111
+ maxResponseBytes = DEFAULT_PYPI_MAX_RESPONSE_BYTES,
112
+ ttlMs = DEFAULT_PYPI_METADATA_TTL_MS,
113
+ offline = false,
114
+ userAgent = USER_AGENT
115
+ } = {}) {
116
+ const normalizedRegistryBaseUrl = normalizePypiRegistryBaseUrl(registryBaseUrl);
117
+ validateRegistryResponseLimit(maxResponseBytes, { errorPrefix: 'PYPI' });
118
+ // Validate future JSON Index policy input even though MVP-02-05 makes no index request.
119
+ normalizePypiRegistryBaseUrl(indexBaseUrl);
120
+ const knowledgeCache = cache ?? createKnowledgeCache({ clock });
121
+ if (!knowledgeCache || typeof knowledgeCache.read !== 'function' || typeof knowledgeCache.write !== 'function') {
122
+ throw new Error('PyPI Registry adapter requires a Knowledge Store cache.');
123
+ }
124
+
125
+ function requestFor(researchPackage) {
126
+ const input = validatePypiResearchPackage(researchPackage);
127
+ return pypiRegistryUrls(normalizedRegistryBaseUrl, input.normalizedName).apiUrl;
128
+ }
129
+
130
+ return {
131
+ requestFor,
132
+ cacheIdentityFor(researchPackage) {
133
+ return cacheIdentityFor(validatePypiResearchPackage(researchPackage));
134
+ },
135
+ async researchPackage(researchPackage) {
136
+ const input = validatePypiResearchPackage(researchPackage);
137
+ const identity = cacheIdentityFor(input);
138
+ const cached = await knowledgeCache.read(identity);
139
+ if (cached.status === 'fresh') {
140
+ try {
141
+ return normalizedResult(input, cached.body, normalizedRegistryBaseUrl, cacheOutcome(cached), cached);
142
+ } catch {
143
+ return unavailableResult(input, normalizedRegistryBaseUrl, cacheOutcome(cached), {
144
+ warningCode: 'REGISTRY_RESPONSE_INVALID',
145
+ message: 'PyPI project metadata is invalid.',
146
+ retryable: true,
147
+ errorCode: 'PYPI_PROJECT_INVALID'
148
+ });
149
+ }
150
+ }
151
+
152
+ const previousCacheOutcome = cacheOutcome(cached);
153
+ if (offline) {
154
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
155
+ warningCode: 'OFFLINE_CACHE_MISS',
156
+ message: 'Offline mode has no fresh PyPI project metadata in cache.',
157
+ retryable: false,
158
+ errorCode: 'PYPI_OFFLINE_CACHE_MISS'
159
+ });
160
+ }
161
+ let response;
162
+ try {
163
+ response = await fetchRegistryJson(requestFor(input), {
164
+ fetchImplementation,
165
+ timeoutMs,
166
+ maxResponseBytes,
167
+ userAgent,
168
+ errorPrefix: 'PYPI',
169
+ serviceName: 'PyPI'
170
+ });
171
+ } catch (error) {
172
+ if (error instanceof BoundedFetchError
173
+ && ['PYPI_RESPONSE_INVALID', 'PYPI_RESPONSE_TOO_LARGE'].includes(error.code)) {
174
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
175
+ warningCode: 'REGISTRY_RESPONSE_INVALID',
176
+ message: 'PyPI returned an invalid project metadata response.',
177
+ retryable: true,
178
+ errorCode: error.code
179
+ });
180
+ }
181
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
182
+ warningCode: 'REGISTRY_UNAVAILABLE',
183
+ message: 'PyPI project metadata is unavailable.',
184
+ retryable: true,
185
+ errorCode: error instanceof BoundedFetchError ? error.code : 'PYPI_TRANSPORT_FAILED'
186
+ });
187
+ }
188
+
189
+ if (response.status === 404) {
190
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
191
+ status: 'notFound', warningCode: 'PACKAGE_NOT_FOUND',
192
+ message: 'Package was not found in PyPI.', retryable: false, errorCode: 'PYPI_HTTP_404'
193
+ });
194
+ }
195
+ if (response.status === 429) {
196
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
197
+ warningCode: 'REGISTRY_RATE_LIMITED',
198
+ message: 'PyPI rate limited project metadata retrieval.', retryable: true, errorCode: 'PYPI_HTTP_429'
199
+ });
200
+ }
201
+ if (response.status !== 200 && response.status >= 200 && response.status < 300) {
202
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
203
+ warningCode: 'REGISTRY_RESPONSE_INVALID',
204
+ message: 'PyPI returned an invalid project metadata response.', retryable: true,
205
+ errorCode: `PYPI_HTTP_${response.status}`
206
+ });
207
+ }
208
+ if (response.status !== 200) {
209
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
210
+ warningCode: 'REGISTRY_UNAVAILABLE',
211
+ message: 'PyPI project metadata is unavailable.', retryable: response.status >= 500,
212
+ errorCode: `PYPI_HTTP_${response.status}`
213
+ });
214
+ }
215
+
216
+ try {
217
+ validatePypiProject(response.body, input.normalizedName);
218
+ } catch {
219
+ return unavailableResult(input, normalizedRegistryBaseUrl, previousCacheOutcome, {
220
+ warningCode: 'REGISTRY_RESPONSE_INVALID',
221
+ message: 'PyPI returned an invalid project metadata response.', retryable: true,
222
+ errorCode: 'PYPI_PROJECT_INVALID'
223
+ });
224
+ }
225
+
226
+ const cacheBody = sanitizeRegistryBodyForCache(response.body);
227
+ const stored = await knowledgeCache.write(identity, cacheBody, { ttlMs });
228
+ const fetchedCache = {
229
+ outcome: cached.status === 'expired' ? 'revalidated' : cached.status === 'corrupted' ? 'corrupted-replaced' : 'miss'
230
+ };
231
+ if (cached.status === 'corrupted') fetchedCache.reason = cached.reason;
232
+ return normalizedResult(input, cacheBody, normalizedRegistryBaseUrl, fetchedCache, stored);
233
+ }
234
+ };
235
+ }
@@ -0,0 +1,51 @@
1
+ import { compareText } from '../portable.js';
2
+
3
+ const PRIVATE_KEYS = new Set([
4
+ 'authorization', 'proxyauthorization', 'cookie', 'setcookie', 'etag',
5
+ 'lastmodified', 'headers', 'cachekey', 'stack', 'password', 'credential',
6
+ 'credentials', 'secret', 'token', 'accesstoken', 'apikey', 'clientsecret', 'privatekey'
7
+ ]);
8
+
9
+ function plainObject(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
11
+ const prototype = Object.getPrototypeOf(value);
12
+ return prototype === Object.prototype || prototype === null;
13
+ }
14
+
15
+ function privateKey(key) {
16
+ return PRIVATE_KEYS.has(key.toLowerCase().replace(/[-_]/g, ''));
17
+ }
18
+
19
+ function unsafeStoredString(value) {
20
+ return value.startsWith('/')
21
+ || /^[A-Za-z]:[\\/]/.test(value)
22
+ || /[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(value);
23
+ }
24
+
25
+ function sanitizeStoredString(value) {
26
+ if (unsafeStoredString(value)) return null;
27
+ const prefix = value.startsWith('git+') ? 'git+' : '';
28
+ const candidate = prefix ? value.slice(prefix.length) : value;
29
+ try {
30
+ const url = new URL(candidate);
31
+ if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) return value;
32
+ if (url.username || url.password) return null;
33
+ url.search = '';
34
+ url.hash = '';
35
+ return `${prefix}${url.toString()}`;
36
+ } catch {
37
+ return value;
38
+ }
39
+ }
40
+
41
+ /** Keep publisher-controlled response bodies safe for private cache storage. */
42
+ export function sanitizeRegistryBodyForCache(value) {
43
+ if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
44
+ if (typeof value === 'string') return sanitizeStoredString(value);
45
+ if (Array.isArray(value)) return value.map(sanitizeRegistryBodyForCache);
46
+ if (!plainObject(value)) return null;
47
+ return Object.fromEntries(Object.keys(value).sort(compareText).flatMap((key) => {
48
+ if (privateKey(key)) return [];
49
+ return [[key, sanitizeRegistryBodyForCache(value[key])]];
50
+ }));
51
+ }