From 7f46af4631249f6efeb5a2b876086be5f7dfa635 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 18 Aug 2026 14:03:05 -0600 Subject: [PATCH 1/2] feat(release): require a version bump for a manifest or export change --- .github/workflows/ci.yml | 4 + api-surface.json | 923 +++++++++++++++++++++++++++++++ package.json | 11 +- pnpm-lock.yaml | 218 ++++++++ scripts/check-api-surface.mjs | 229 ++++++++ scripts/check-version-bump.mjs | 511 +++++++++++++++++ scripts/lib/api-surface.mjs | 295 ++++++++++ tests/version-bump-check.test.ts | 634 +++++++++++++++++++++ 8 files changed, 2822 insertions(+), 3 deletions(-) create mode 100644 api-surface.json create mode 100644 scripts/check-api-surface.mjs create mode 100644 scripts/check-version-bump.mjs create mode 100644 scripts/lib/api-surface.mjs create mode 100644 tests/version-bump-check.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a81b54..235b553 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # check:version-bump diffs this branch against the merge base, so the + # base commit has to be in the clone. + fetch-depth: 0 - uses: pnpm/action-setup@v6 diff --git a/api-surface.json b/api-surface.json new file mode 100644 index 0000000..19f1efe --- /dev/null +++ b/api-surface.json @@ -0,0 +1,923 @@ +{ + "package": "@tangle-network/agent-knowledge", + "entries": { + ".": { + "AdaptiveDecision": "value", + "AdaptiveDriverOptions": "value", + "AdaptiveResearchDriver": "value", + "AdaptiveStats": "value", + "AddSourceOptions": "value", + "AddSourceTextInput": "value", + "AgentMemoryAcquireRunLease": "value", + "AgentMemoryActivation": "type", + "AgentMemoryActivationDriver": "type", + "AgentMemoryAdapter": "value", + "AgentMemoryAttemptEvent": "type", + "AgentMemoryBranch": "value", + "AgentMemoryBranchIsolation": "value", + "AgentMemoryBranchLifetime": "value", + "AgentMemoryBranchSnapshot": "value", + "AgentMemoryContext": "value", + "AgentMemoryControllerMode": "value", + "AgentMemoryDimensionComparison": "type", + "AgentMemoryEvidenceRef": "type", + "AgentMemoryExecutionContext": "type", + "AgentMemoryExecutionCostMeter": "type", + "AgentMemoryExecutionCostReceipt": "type", + "AgentMemoryExecutionPaidCallInput": "type", + "AgentMemoryExecutionPaidCallResult": "type", + "AgentMemoryExecutionStep": "type", + "AgentMemoryExperimentCandidate": "type", + "AgentMemoryExperimentCandidateRef": "type", + "AgentMemoryExperimentComparisonRef": "type", + "AgentMemoryExperimentRankingRow": "type", + "AgentMemoryExperimentRunLease": "type", + "AgentMemoryFinalEvaluation": "type", + "AgentMemoryFinalPair": "type", + "AgentMemoryForgettingComparison": "type", + "AgentMemoryHit": "value", + "AgentMemoryHitSchema": "value", + "AgentMemoryImprovementRunLease": "type", + "AgentMemoryJournalEntry": "value", + "AgentMemoryKind": "value", + "AgentMemoryKindSchema": "value", + "AgentMemoryLearningArmOrder": "type", + "AgentMemoryLearningCandidateSummary": "type", + "AgentMemoryLearningCellComparison": "type", + "AgentMemoryLearningComparison": "type", + "AgentMemoryLifecycleTimeoutError": "value", + "AgentMemoryLifecycleUnsafeError": "value", + "AgentMemoryMode": "type", + "AgentMemoryPromotionDecision": "type", + "AgentMemoryRunLease": "value", + "AgentMemoryScope": "value", + "AgentMemoryScopeSchema": "value", + "AgentMemorySearchOptions": "value", + "AgentMemorySequence": "type", + "AgentMemorySequenceArtifact": "type", + "AgentMemorySequenceProbe": "type", + "AgentMemorySequenceProbeResult": "type", + "AgentMemorySequenceScenario": "type", + "AgentMemorySequenceStep": "type", + "AgentMemorySharingPolicy": "value", + "AgentMemoryTransferCellComparison": "type", + "AgentMemoryTransferStepSummary": "type", + "AgentMemoryVisibility": "value", + "AgentMemoryWriteInput": "value", + "AgentMemoryWriteInputSchema": "value", + "AgentMemoryWriteResult": "value", + "ApplyWriteBlocksResult": "value", + "AuditKnowledgeCitationsOptions": "value", + "BuildAgentMemorySequencesFromBenchmarkCasesOptions": "type", + "BuildEvalKnowledgeBundleOptions": "value", + "BuildRetrievalBenchmarkCasesFromQrelsOptions": "type", + "BuildRetrievalEvalDispatchOptions": "value", + "CHECKABLE_RUNG_THRESHOLD": "value", + "CheckExecution": "value", + "ChunkingOptions": "value", + "ClaimEvidence": "value", + "ClaimGrade": "value", + "ClaimGroundingDriverOptions": "value", + "ClaimLedgerGoalConflictError": "value", + "ClaimLedgerMigrationRequiredError": "value", + "ClaimLedgerStore": "value", + "ClaimRef": "value", + "ClaimVerdict": "value", + "CompanyEvalCase": "value", + "CompareAgentMemoryLearningOptions": "type", + "CornellLiiSelector": "value", + "CornellLiiSourceOptions": "value", + "CreateAgentMemoryBranchOptions": "value", + "CreateKnowledgeRetrievalReceiptInput": "value", + "CreateKnowledgeUseReceiptInput": "value", + "D1Adapter": "value", + "DEFAULT_MEMORY_CLEANUP_TIMEOUT_MS": "value", + "DedupReason": "value", + "DeepQuestion": "value", + "DeepQuestionKind": "value", + "DeepQuestionSchema": "value", + "DefineReadinessSpecInput": "value", + "DetectChangesOptions": "value", + "DetectChangesResult": "value", + "DiscoveryLoopResult": "value", + "DiscoveryLoopRound": "value", + "DiscoveryLoopStopReason": "value", + "DiscoveryResult": "value", + "DiscoveryTask": "value", + "DriverResearchContext": "value", + "EvalKnowledgeBundleBuildResult": "value", + "EvaluateKnowledgeBaseReadinessOptions": "value", + "EvidenceRung": "value", + "ExpectedGroup": "value", + "ExternalRagEvalScore": "type", + "FactResult": "value", + "FetchOpts": "value", + "FileSystemFreshnessStoreOptions": "value", + "FileSystemKbStore": "value", + "FileSystemKbStoreOptions": "value", + "FileSystemSearchOptions": "value", + "FileSystemSearchProvider": "value", + "FileSystemSearchProviderOptions": "value", + "ForkAgentMemoryBranchSnapshotOptions": "value", + "FragmentProvenance": "value", + "FreshnessKey": "value", + "FreshnessMark": "value", + "FreshnessRecord": "value", + "FreshnessTtl": "value", + "GraphitiMcpClientLike": "value", + "GraphitiMemoryAdapterOptions": "value", + "GraphitiToolNames": "value", + "GroundClaimOptions": "value", + "GroundingResult": "value", + "INDUSTRY_MEMORY_BENCHMARKS": "value", + "INDUSTRY_RAG_BENCHMARKS": "value", + "IRS_DIMENSION_HINTS": "value", + "ImproveSelectedKnowledgeCandidateOptions": "type", + "ImproveSelectedKnowledgeCandidateResult": "type", + "IrsPublicationsSourceOptions": "value", + "KB_CLAIM_LEDGER_DIR": "value", + "KB_EVENTS_PATH": "value", + "KB_INDEX_PATH": "value", + "KB_STORE_DIR": "value", + "KNOWLEDGE_EVENT_TYPES": "value", + "KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM": "value", + "KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION": "value", + "KbStore": "value", + "KnowledgeAnswerBenchmarkCase": "type", + "KnowledgeAnswerBenchmarkTaskKind": "type", + "KnowledgeBaseCandidate": "value", + "KnowledgeBaseCandidateSchema": "value", + "KnowledgeBaseQualityOptions": "type", + "KnowledgeBaseQualityReport": "type", + "KnowledgeBaseReadinessEvaluation": "value", + "KnowledgeBenchmarkArtifact": "type", + "KnowledgeBenchmarkCase": "type", + "KnowledgeBenchmarkCaseBase": "type", + "KnowledgeBenchmarkDistribution": "type", + "KnowledgeBenchmarkEvaluation": "type", + "KnowledgeBenchmarkFamily": "type", + "KnowledgeBenchmarkReport": "type", + "KnowledgeBenchmarkResponder": "type", + "KnowledgeBenchmarkScenario": "type", + "KnowledgeBenchmarkSliceSummary": "type", + "KnowledgeBenchmarkSource": "type", + "KnowledgeBenchmarkSpec": "type", + "KnowledgeBenchmarkSplit": "type", + "KnowledgeBenchmarkTaskKind": "type", + "KnowledgeChange": "value", + "KnowledgeChangeKind": "value", + "KnowledgeChunk": "value", + "KnowledgeCitationAuditError": "value", + "KnowledgeCitationAuditIssue": "value", + "KnowledgeCitationAuditIssueKind": "value", + "KnowledgeCitationAuditReport": "value", + "KnowledgeCitationCandidate": "value", + "KnowledgeCitationReference": "value", + "KnowledgeCitationResolution": "value", + "KnowledgeCitationResolutionError": "value", + "KnowledgeCitationResolutionStatus": "value", + "KnowledgeClaim": "value", + "KnowledgeClaimMatcher": "type", + "KnowledgeConsumerKind": "value", + "KnowledgeConsumerRef": "value", + "KnowledgeControlLoopAction": "value", + "KnowledgeControlLoopActionResult": "value", + "KnowledgeControlLoopAdapter": "value", + "KnowledgeControlLoopAdapterOptions": "value", + "KnowledgeControlLoopState": "value", + "KnowledgeDiscoveryDispatcher": "value", + "KnowledgeDiscoveryWorker": "value", + "KnowledgeEvaluationPhase": "type", + "KnowledgeEvent": "value", + "KnowledgeEventQuery": "value", + "KnowledgeEventSchema": "value", + "KnowledgeEventType": "value", + "KnowledgeExplanation": "value", + "KnowledgeFragment": "value", + "KnowledgeFreshnessStore": "value", + "KnowledgeGap": "value", + "KnowledgeGraph": "value", + "KnowledgeGraphEdge": "value", + "KnowledgeGraphEdgeSchema": "value", + "KnowledgeGraphNode": "value", + "KnowledgeGraphNodeSchema": "value", + "KnowledgeId": "value", + "KnowledgeImprovementActivationPersistence": "type", + "KnowledgeImprovementCandidateRecord": "type", + "KnowledgeImprovementCandidateRef": "type", + "KnowledgeImprovementCandidateRefSchema": "value", + "KnowledgeImprovementEvaluationInput": "type", + "KnowledgeImprovementEvaluator": "type", + "KnowledgeImprovementEvent": "type", + "KnowledgeImprovementEvidence": "type", + "KnowledgeImprovementEvidenceSchema": "value", + "KnowledgeImprovementMetric": "type", + "KnowledgeImprovementMetricProvenance": "type", + "KnowledgeImprovementMutationReceipt": "type", + "KnowledgeImprovementMutationResult": "type", + "KnowledgeImprovementOptions": "type", + "KnowledgeImprovementRagOptimizationOptions": "type", + "KnowledgeImprovementRagOptimizationRunInput": "type", + "KnowledgeImprovementResult": "type", + "KnowledgeImprovementRetrievalOptions": "type", + "KnowledgeImprovementRunState": "type", + "KnowledgeImprovementRunStateSchema": "value", + "KnowledgeImprovementStatus": "type", + "KnowledgeImprovementTarget": "type", + "KnowledgeImprovementUpdate": "type", + "KnowledgeImprovementUpdateInput": "type", + "KnowledgeIndex": "value", + "KnowledgeIndexSchema": "value", + "KnowledgeInspection": "value", + "KnowledgeLayout": "value", + "KnowledgeLintFinding": "value", + "KnowledgeMemoryBenchmarkCase": "type", + "KnowledgeMemoryBenchmarkTaskKind": "type", + "KnowledgeMemoryEvent": "type", + "KnowledgeMemoryFactMatcher": "type", + "KnowledgeMutationLock": "type", + "KnowledgeMutationOptions": "type", + "KnowledgePage": "value", + "KnowledgePageInvalidation": "value", + "KnowledgePageInvalidationSchema": "value", + "KnowledgePageSchema": "value", + "KnowledgePolicy": "value", + "KnowledgePolicyDispatch": "type", + "KnowledgeProposal": "value", + "KnowledgeProposalParseError": "value", + "KnowledgeReadOptions": "type", + "KnowledgeReadinessSpec": "value", + "KnowledgeReceiptAttributeValue": "value", + "KnowledgeRelation": "value", + "KnowledgeRelease": "value", + "KnowledgeReleaseInput": "value", + "KnowledgeReleaseReport": "value", + "KnowledgeResearchLoopContext": "value", + "KnowledgeResearchLoopDecision": "value", + "KnowledgeResearchLoopResult": "value", + "KnowledgeResearchLoopStep": "value", + "KnowledgeRetrievalBenchmarkCase": "type", + "KnowledgeRetrievalBenchmarkQrel": "type", + "KnowledgeRetrievalBenchmarkQuery": "type", + "KnowledgeRetrievalReceipt": "value", + "KnowledgeRetrievalResultReceipt": "value", + "KnowledgeRetrieverIdentity": "value", + "KnowledgeSearchHit": "value", + "KnowledgeSearchResult": "value", + "KnowledgeSource": "value", + "KnowledgeUnit": "value", + "KnowledgeUseReceipt": "value", + "KnowledgeUseRelation": "value", + "KnowledgeUsedResult": "value", + "KnowledgeVisibilitySnapshot": "value", + "KnowledgeVisibilitySnapshotEntry": "value", + "KnowledgeWriteBlock": "value", + "KnowledgeWriteParseResult": "value", + "LoadKnowledgeImprovementActivationResultOptions": "type", + "LoadKnowledgePagesOptions": "value", + "MAX_RESPONSE_BYTES": "value", + "MIN_REQUEST_GAP_MS": "value", + "MaterialFact": "value", + "MaterialFactLens": "value", + "MaterialFactsResult": "value", + "MeasuredKnowledgeSelectionReceipt": "type", + "Mem0ClientMode": "value", + "Mem0HostedClient": "value", + "Mem0HostedMemoryAdapterOptions": "value", + "Mem0MemoryAdapterOptions": "value", + "Mem0OssClient": "value", + "Mem0OssMemoryAdapterOptions": "value", + "MemoryAdapterBenchmarkCandidate": "type", + "MemoryAdapterBenchmarkRankingRow": "type", + "MemoryConfigScenario": "type", + "MemoryKbStore": "value", + "NearDuplicateDetectionOptions": "type", + "NearDuplicatePair": "type", + "NearDuplicateReport": "type", + "Neo4jAgentMemoryAdapterOptions": "value", + "OptimizeKnowledgeBasePolicyOptions": "type", + "OptimizeKnowledgeBasePolicyResult": "type", + "OriginatedKnowledgeSearchResult": "value", + "OriginatedPage": "value", + "OwnedAgentMemoryRunLease": "value", + "POLITE_USER_AGENT": "value", + "PageOrigin": "value", + "ParsedFrontmatter": "value", + "PartitionRetrievalScenariosOptions": "value", + "PendingKnowledgeMutation": "type", + "PersistentResearchDrivingDriverOptions": "value", + "PoliteFetchOptions": "value", + "PoliteFetchResult": "value", + "PromoteKnowledgeCandidateOptions": "type", + "ProposeFromFindingsResult": "value", + "READINESS_SPEC_DEFAULTS": "value", + "RUN_LINEAGE_BASENAME": "value", + "RagAnswerEvalArtifact": "type", + "RagAnswerEvalCase": "type", + "RagAnswerEvalScenario": "type", + "RagAnswerMetricSummary": "type", + "RagAnswerQualityHookOptions": "type", + "RagAnswerQualityInput": "value", + "RagAnswerQualityJudgeOptions": "type", + "RagAnswerQualityResult": "value", + "RagCalibrationOptions": "type", + "RagCalibrationResult": "type", + "RagDiagnosisInput": "value", + "RagEvalCitation": "type", + "RagEvalClaim": "type", + "RagEvalContext": "type", + "RagEvalMetricKey": "type", + "RagEvalProvider": "type", + "RagEvalSlice": "type", + "RagGapFinding": "value", + "RagGapKind": "value", + "RagGapSeverity": "value", + "RagKnowledgeAcquisitionInput": "value", + "RagKnowledgeImprovementPhase": "value", + "RagKnowledgeImprovementPhaseResult": "value", + "RagKnowledgeImprovementPhaseStatus": "value", + "RagKnowledgeResearchOptions": "value", + "RagKnowledgeUpdateInput": "value", + "RagKnowledgeUpdateResult": "value", + "RagOptimizationConfig": "value", + "RagOptimizationSelection": "value", + "RagPhaseInputBase": "value", + "RagPromotionInput": "value", + "RagPromotionResult": "value", + "RagRequiredContext": "type", + "RecoverPendingKnowledgeMutationOptions": "type", + "RegularFileSnapshot": "value", + "RejectedSource": "value", + "ResearchClaimEvidence": "value", + "ResearchClaimEvidenceSchema": "value", + "ResearchClaimLedger": "value", + "ResearchClaimLedgerSchema": "value", + "ResearchClaimRecord": "value", + "ResearchClaimRecordSchema": "value", + "ResearchContribution": "value", + "ResearchDriver": "value", + "ResearchDrivingDriver": "value", + "ResearchDrivingDriverOptions": "value", + "ResearchDrivingState": "value", + "ResearchDrivingSteer": "value", + "ResearchSourceProposal": "value", + "ResearchSourceVersion": "value", + "ResearchSourceVersionSchema": "value", + "ResearchWorker": "value", + "ResolvedKnowledgeImprovementCandidate": "type", + "ResolvedKnowledgeImprovementComparison": "type", + "ResolvedKnowledgeImprovementComparisonSnapshot": "type", + "RestoreKnowledgeCandidateBaselineOptions": "type", + "RetrievalConfig": "value", + "RetrievalEvalArtifact": "value", + "RetrievalEvalRetriever": "value", + "RetrievalEvalRetrieverInput": "value", + "RetrievalEvalRetrieverResult": "value", + "RetrievalEvalScenario": "value", + "RetrievalGoldTarget": "value", + "RetrievalHoldoutBypassReason": "value", + "RetrievalHoldoutCallContext": "value", + "RetrievalHoldoutConfig": "value", + "RetrievalHoldoutEligibleItem": "value", + "RetrievalHoldoutEvent": "value", + "RetrievalHoldoutOffPolicyOptions": "value", + "RetrievalHoldoutOffPolicyResult": "value", + "RetrievalHoldoutResult": "value", + "RetrievalHoldoutSessionState": "value", + "RetrievalHoldoutSessionSummary": "value", + "RetrievalMetricSummary": "value", + "RetrievalMetricWeights": "value", + "RetrievalOptimizationSelection": "value", + "RetrievalRecallJudgeOptions": "value", + "RetrievalScenarioPartitions": "value", + "RetrievedKnowledgeHit": "value", + "RetrievedSourceSpan": "value", + "RouterClient": "value", + "RouterError": "value", + "RouterUsage": "value", + "RunAgentMemoryExperimentOptions": "type", + "RunAgentMemoryExperimentResult": "type", + "RunAgentMemoryImprovementOptions": "type", + "RunAgentMemoryImprovementResult": "type", + "RunAgentMemoryLearningExperimentOptions": "type", + "RunAgentMemoryLearningExperimentResult": "type", + "RunDiscoveryLoopOptions": "value", + "RunKnowledgeBenchmarkSuiteOptions": "type", + "RunKnowledgeBenchmarkSuiteResult": "type", + "RunKnowledgeResearchLoopOptions": "value", + "RunLineageAuthority": "value", + "RunLineageRecord": "value", + "RunMemoryAdapterBenchmarkOptions": "type", + "RunMemoryAdapterBenchmarkResult": "type", + "RunRagKnowledgeImprovementLoopOptions": "value", + "RunRagKnowledgeImprovementLoopResult": "value", + "RunRagOptimizationOptions": "value", + "RunRagOptimizationResult": "value", + "RunRetrievalImprovementLoopOptions": "value", + "RunRetrievalImprovementLoopResult": "value", + "RunScopedStores": "value", + "RunScopedStoresOptions": "value", + "RunSerializedKnowledgeOptimizationOptions": "value", + "RunSerializedKnowledgeOptimizationResult": "value", + "SCAFFOLD_PAGE_BASENAMES": "value", + "SearchKnowledgeOptions": "value", + "SerializedCandidate": "value", + "SerializedCandidateCodec": "value", + "SourceAdapter": "value", + "SourceAdapterInput": "value", + "SourceAdapterOutput": "value", + "SourceAnchor": "value", + "SourceAnchorSchema": "value", + "SourceFreshnessInspection": "value", + "SourceRecord": "value", + "SourceRecordSchema": "value", + "SourceRegistry": "value", + "SourceVerdict": "value", + "SourceVerificationContext": "value", + "StateSosEntity": "value", + "StateSosSourceConfig": "value", + "TangleRouterOptions": "value", + "ThesisRunOptions": "value", + "ThesisRunResult": "value", + "ThesisTaskInput": "value", + "TrackedClaim": "value", + "TriageClass": "value", + "UncheckableClaimError": "value", + "UseKnowledgeImprovementCandidateOptions": "type", + "ValidateKnowledgeOptions": "value", + "ValidateKnowledgeResult": "value", + "VerifiedGradeableEvidence": "value", + "VerifiedResearchLoopOptions": "value", + "VerifiedResearchLoopResult": "value", + "VerifiedResearchRound": "value", + "VerifyGradeableEvidenceOptions": "value", + "VerifyingDriverOptions": "value", + "WIKILINK_REGEX": "value", + "WebResearchWorkerOptions": "value", + "WebSearchHit": "value", + "WorkerClaimDecorationOptions": "value", + "WorkerResearchContext": "value", + "__resetHttpThrottle": "value", + "acquireAgentMemoryRunLease": "value", + "addSourcePath": "value", + "addSourceText": "value", + "agentMemorySequenceJudge": "value", + "applyKnowledgeWriteBlocks": "value", + "applyKnowledgeWriteBlocksFile": "value", + "applyRetrievalHoldout": "value", + "applySessionStickyRetrievalHoldout": "value", + "assertClaimLedgerId": "value", + "assertCurrentRunCitationsResolved": "value", + "assertDeepQuestionIntegrity": "value", + "assertGradeableEvidence": "value", + "assertKnowledgeCitationAudit": "value", + "assertKnowledgeCitationsResolved": "value", + "assertKnowledgeRetrievalMatchesVisibility": "value", + "assertResearchClaimEvidenceIntegrity": "value", + "assertResearchClaimLedgerIntegrity": "value", + "assertRunScopedCitationsResolved": "value", + "assertTrackedClaimIntegrity": "value", + "auditCurrentRunCitations": "value", + "auditKnowledgeCitations": "value", + "buildAgentMemorySequenceScenarios": "value", + "buildAgentMemorySequencesFromBenchmarkCases": "value", + "buildEvalKnowledgeBundle": "value", + "buildFirstPartyMemoryLifecycleBenchmarkCases": "value", + "buildIndustryMemoryBenchmarkSmokeCases": "value", + "buildIndustryRagBenchmarkSmokeCases": "value", + "buildKnowledgeBenchmarkScenarios": "value", + "buildKnowledgeGraph": "value", + "buildKnowledgeIndex": "value", + "buildRetrievalBenchmarkCasesFromQrels": "value", + "buildRetrievalEvalDispatch": "value", + "calibrateRagAnswerJudge": "value", + "canonicalizeUrl": "value", + "chunkMarkdown": "value", + "citedClaimKey": "value", + "citedClaimOf": "value", + "claimEvidenceId": "value", + "claimId": "value", + "claimSourceHost": "value", + "compareAgentMemoryLearning": "value", + "contentKey": "value", + "createAdaptiveResearchDriver": "value", + "createAgentMemoryBranch": "value", + "createClaimDecorator": "value", + "createClaimGroundingVerifier": "value", + "createCollectionResearchDriver": "value", + "createCornellLiiSource": "value", + "createD1FreshnessStoreStub": "value", + "createFileRunLineageAuthority": "value", + "createFileSystemFreshnessStore": "value", + "createFileSystemSearchProvider": "value", + "createGraphitiMemoryAdapter": "value", + "createInMemoryBenchmarkAdapter": "value", + "createIrsPublicationsSource": "value", + "createKnowledgeControlLoopAdapter": "value", + "createKnowledgeEvent": "value", + "createKnowledgeRetrievalReceipt": "value", + "createKnowledgeUseReceipt": "value", + "createKnowledgeVisibilitySnapshot": "value", + "createLocalDiscoveryDispatcher": "value", + "createMem0MemoryAdapter": "value", + "createMemoryExecutionPool": "value", + "createNeo4jAgentMemoryAdapter": "value", + "createNoopMemoryBenchmarkAdapter": "value", + "createPersistentResearchDrivingDriver": "value", + "createRagAnswerQualityHook": "value", + "createResearchDrivingDriver": "value", + "createRunScopedStores": "value", + "createStateSosSource": "value", + "createTangleRouterClient": "value", + "createVerifyingResearchDriver": "value", + "createWebResearchWorker": "value", + "deepQuestionId": "value", + "defaultGetMemoryContext": "value", + "defineReadinessSpec": "value", + "detectChanges": "value", + "detectNearDuplicatePages": "value", + "deterministicRng": "value", + "diagnoseRagAnswerFailure": "value", + "emitRetrievalHoldoutBypass": "value", + "emptyClaimLedger": "value", + "evaluateKnowledgeBaseReadiness": "value", + "explainKnowledgeTarget": "value", + "extractLinks": "value", + "extractWikilinks": "value", + "firstMatch": "value", + "forkAgentMemoryBranchSnapshot": "value", + "formatFrontmatter": "value", + "formatKnowledgeCitationReference": "value", + "fromAgentCandidateKnowledgeRef": "value", + "gradeCompanyAgainstText": "value", + "gradeFactAgainstText": "value", + "gradeFor": "value", + "graphitiMemoryAdapterIdentity": "value", + "groundClaimInText": "value", + "hashKnowledgeBase": "value", + "htmlToText": "value", + "improveKnowledgeBase": "value", + "improveSelectedKnowledgeCandidate": "value", + "initKnowledgeBase": "value", + "innerHtmlById": "value", + "inspectKnowledgeIndex": "value", + "inspectPendingKnowledgeMutation": "value", + "investmentThesisSet": "value", + "isKernelAnchoredPath": "value", + "isKnowledgeMemoryBenchmarkCase": "value", + "isMissingFile": "value", + "isSafeKnowledgePath": "value", + "isScaffoldPath": "value", + "jsonCandidateCodec": "value", + "jsonObjectCandidateCodec": "value", + "kbIndexToText": "value", + "knowledgeBenchmarkJudge": "value", + "knowledgeCitationAuditFindings": "value", + "knowledgeImprovementCandidateRef": "value", + "knowledgeImprovementRunDir": "value", + "knowledgeImprovementRunId": "value", + "knowledgePageDigest": "value", + "knowledgeReleaseReport": "value", + "layoutFor": "value", + "lensDistribution": "value", + "linkClaimContradictions": "value", + "lintCurrentRunCitations": "value", + "lintKnowledgeIndex": "value", + "listRegularFilesWithinRoot": "value", + "loadKnowledgeImprovementActivationResult": "value", + "loadKnowledgeImprovementEvents": "value", + "loadKnowledgeImprovementState": "value", + "loadKnowledgePages": "value", + "loadSourceRegistry": "value", + "looksLikeBlockPage": "value", + "materialFactsSurfaced": "value", + "materialFactsSurfacedInText": "value", + "materializeRegisteredClaimEvidence": "value", + "mediaTypeFor": "value", + "mem0MemoryAdapterIdentity": "value", + "memoryHitToSourceRecord": "value", + "memoryRecoveryDelayMs": "value", + "memoryWriteResultToSourceRecord": "value", + "mergeClaimLedgers": "value", + "mergeTrackedClaims": "value", + "normalizeClaimText": "value", + "normalizeExternalRagScores": "value", + "normalizeLinkTarget": "value", + "normalizePageText": "value", + "optimizeKnowledgeBasePolicy": "value", + "parseFrontmatter": "value", + "parseKnowledgeBenchmarkJsonl": "value", + "parseKnowledgeBenchmarkQrels": "value", + "parseKnowledgeCitationReference": "value", + "parseKnowledgeWriteBlocks": "value", + "partitionRetrievalScenarios": "value", + "politeFetch": "value", + "promoteKnowledgeCandidate": "value", + "proposeFromFinding": "value", + "proposeFromFindings": "value", + "ragAnswerQualityJudge": "value", + "readRegularFileNoFollow": "value", + "readRegularFileWithinRoot": "value", + "reciprocalRankFusion": "value", + "recoverPendingKnowledgeMutation": "value", + "removeDurable": "value", + "renameDurable": "value", + "renderKnowledgeBenchmarkReportMarkdown": "value", + "renderMemoryContext": "value", + "researchSourceVersionKey": "value", + "resetRetrievalHoldoutRegistry": "value", + "resolveKnowledgeCitation": "value", + "resolveKnowledgeCitations": "value", + "resolveMemoryCleanupTimeoutMs": "value", + "resolveRunScopedCitation": "value", + "resolveRunScopedCitations": "value", + "respondToIndustryMemoryBenchmarkSmokeCase": "value", + "respondToIndustryRagBenchmarkSmokeCase": "value", + "restoreKnowledgeCandidateBaseline": "value", + "retrievalConfigFromSurface": "value", + "retrievalConfigSurface": "value", + "retrievalHoldoutConfigHash": "value", + "retrievalRecallJudge": "value", + "runAgentMemoryExperiment": "value", + "runAgentMemoryImprovement": "value", + "runAgentMemoryLearningExperiment": "value", + "runBoundedMemoryLifecycle": "value", + "runDiscoveryLoop": "value", + "runInvestmentThesisTask": "value", + "runKnowledgeBenchmarkSuite": "value", + "runKnowledgeResearchLoop": "value", + "runMemoryAdapterBenchmark": "value", + "runRagKnowledgeImprovementLoop": "value", + "runRagOptimization": "value", + "runRetrievalImprovementLoop": "value", + "runSerializedKnowledgeOptimization": "value", + "runVerifiedResearchLoop": "value", + "scenarioContentFingerprint": "value", + "scoreKnowledgeBaseIndex": "value", + "scoreKnowledgeBenchmarkArtifact": "value", + "scoreMemoryBenchmarkArtifact": "value", + "scoreRagAnswerArtifact": "value", + "scoreRetrievalArtifact": "value", + "searchKnowledge": "value", + "sha256": "value", + "sleepForMemoryRecovery": "value", + "slugify": "value", + "snapshotSourceTextInput": "value", + "sourceMatchesGaps": "value", + "sourceRegistryPath": "value", + "stableId": "value", + "stripFrontmatter": "value", + "summarizeKnowledgeBenchmarkCampaign": "value", + "syncDirectory": "value", + "textSourceAdapter": "value", + "textSourceId": "value", + "thesisReadinessSpecs": "value", + "toAgentCandidateKnowledgeRef": "value", + "toDeepEvalTestCases": "value", + "toOffPolicyTrajectory": "value", + "toRagCheckerRecords": "value", + "toRagasEvaluationRows": "value", + "toTruLensRecords": "value", + "tokenizeQuery": "value", + "totalMaterialFacts": "value", + "triageSource": "value", + "validateKnowledgeIndex": "value", + "verdictFor": "value", + "verifyGradeableEvidence": "value", + "verifyKnowledgeRetrievalReceipt": "value", + "verifyKnowledgeUseReceipt": "value", + "withCitedClaim": "value", + "withKnowledgeImprovementCandidate": "value", + "withKnowledgeImprovementComparison": "value", + "withKnowledgeMutation": "value", + "withKnowledgeRead": "value", + "withSafeDescendant": "value", + "withSafeDirectory": "value", + "writeFileDurable": "value", + "writeFileDurableWithinRoot": "value", + "writeJson": "value", + "writeJsonDurable": "value", + "writeJsonDurableWithinRoot": "value", + "writeKnowledgeIndex": "value", + "writeSourceRegistry": "value" + }, + "./benchmarks": { + "BuildRetrievalBenchmarkCasesFromQrelsOptions": "type", + "INDUSTRY_MEMORY_BENCHMARKS": "value", + "INDUSTRY_RAG_BENCHMARKS": "value", + "KnowledgeAnswerBenchmarkCase": "type", + "KnowledgeAnswerBenchmarkTaskKind": "type", + "KnowledgeBenchmarkArtifact": "type", + "KnowledgeBenchmarkCase": "type", + "KnowledgeBenchmarkCaseBase": "type", + "KnowledgeBenchmarkDistribution": "type", + "KnowledgeBenchmarkEvaluation": "type", + "KnowledgeBenchmarkFamily": "type", + "KnowledgeBenchmarkReport": "type", + "KnowledgeBenchmarkResponder": "type", + "KnowledgeBenchmarkScenario": "type", + "KnowledgeBenchmarkSliceSummary": "type", + "KnowledgeBenchmarkSource": "type", + "KnowledgeBenchmarkSpec": "type", + "KnowledgeBenchmarkSplit": "type", + "KnowledgeBenchmarkTaskKind": "type", + "KnowledgeClaimMatcher": "type", + "KnowledgeMemoryBenchmarkCase": "type", + "KnowledgeMemoryBenchmarkTaskKind": "type", + "KnowledgeMemoryEvent": "type", + "KnowledgeMemoryFactMatcher": "type", + "KnowledgeRetrievalBenchmarkCase": "type", + "KnowledgeRetrievalBenchmarkQrel": "type", + "KnowledgeRetrievalBenchmarkQuery": "type", + "MemoryAdapterBenchmarkCandidate": "type", + "MemoryAdapterBenchmarkRankingRow": "type", + "RunKnowledgeBenchmarkSuiteOptions": "type", + "RunKnowledgeBenchmarkSuiteResult": "type", + "RunMemoryAdapterBenchmarkOptions": "type", + "RunMemoryAdapterBenchmarkResult": "type", + "buildFirstPartyMemoryLifecycleBenchmarkCases": "value", + "buildIndustryMemoryBenchmarkSmokeCases": "value", + "buildIndustryRagBenchmarkSmokeCases": "value", + "buildKnowledgeBenchmarkScenarios": "value", + "buildRetrievalBenchmarkCasesFromQrels": "value", + "createInMemoryBenchmarkAdapter": "value", + "createNoopMemoryBenchmarkAdapter": "value", + "isKnowledgeMemoryBenchmarkCase": "value", + "knowledgeBenchmarkJudge": "value", + "parseKnowledgeBenchmarkJsonl": "value", + "parseKnowledgeBenchmarkQrels": "value", + "renderKnowledgeBenchmarkReportMarkdown": "value", + "respondToIndustryMemoryBenchmarkSmokeCase": "value", + "respondToIndustryRagBenchmarkSmokeCase": "value", + "runKnowledgeBenchmarkSuite": "value", + "runMemoryAdapterBenchmark": "value", + "scoreKnowledgeBenchmarkArtifact": "value", + "scoreMemoryBenchmarkArtifact": "value", + "summarizeKnowledgeBenchmarkCampaign": "value" + }, + "./cli": {}, + "./memory": { + "AgentMemoryAcquireRunLease": "value", + "AgentMemoryActivation": "type", + "AgentMemoryActivationDriver": "type", + "AgentMemoryAdapter": "value", + "AgentMemoryAttemptEvent": "type", + "AgentMemoryBranch": "value", + "AgentMemoryBranchIsolation": "value", + "AgentMemoryBranchLifetime": "value", + "AgentMemoryBranchSnapshot": "value", + "AgentMemoryContext": "value", + "AgentMemoryControllerMode": "value", + "AgentMemoryDimensionComparison": "type", + "AgentMemoryEvidenceRef": "type", + "AgentMemoryExecutionContext": "type", + "AgentMemoryExecutionCostMeter": "type", + "AgentMemoryExecutionCostReceipt": "type", + "AgentMemoryExecutionPaidCallInput": "type", + "AgentMemoryExecutionPaidCallResult": "type", + "AgentMemoryExecutionStep": "type", + "AgentMemoryExperimentCandidate": "type", + "AgentMemoryExperimentCandidateRef": "type", + "AgentMemoryExperimentComparisonRef": "type", + "AgentMemoryExperimentRankingRow": "type", + "AgentMemoryExperimentRunLease": "type", + "AgentMemoryFinalEvaluation": "type", + "AgentMemoryFinalPair": "type", + "AgentMemoryForgettingComparison": "type", + "AgentMemoryHit": "value", + "AgentMemoryHitSchema": "value", + "AgentMemoryImprovementRunLease": "type", + "AgentMemoryJournalEntry": "value", + "AgentMemoryKind": "value", + "AgentMemoryKindSchema": "value", + "AgentMemoryLearningArmOrder": "type", + "AgentMemoryLearningCandidateSummary": "type", + "AgentMemoryLearningCellComparison": "type", + "AgentMemoryLearningComparison": "type", + "AgentMemoryLifecycleTimeoutError": "value", + "AgentMemoryLifecycleUnsafeError": "value", + "AgentMemoryMode": "type", + "AgentMemoryPromotionDecision": "type", + "AgentMemoryRunLease": "value", + "AgentMemoryScope": "value", + "AgentMemoryScopeSchema": "value", + "AgentMemorySearchOptions": "value", + "AgentMemorySequence": "type", + "AgentMemorySequenceArtifact": "type", + "AgentMemorySequenceProbe": "type", + "AgentMemorySequenceProbeResult": "type", + "AgentMemorySequenceScenario": "type", + "AgentMemorySequenceStep": "type", + "AgentMemorySharingPolicy": "value", + "AgentMemoryTransferCellComparison": "type", + "AgentMemoryTransferStepSummary": "type", + "AgentMemoryVisibility": "value", + "AgentMemoryWriteInput": "value", + "AgentMemoryWriteInputSchema": "value", + "AgentMemoryWriteResult": "value", + "BuildAgentMemorySequencesFromBenchmarkCasesOptions": "type", + "CompareAgentMemoryLearningOptions": "type", + "CreateAgentMemoryBranchOptions": "value", + "DEFAULT_MEMORY_CLEANUP_TIMEOUT_MS": "value", + "ForkAgentMemoryBranchSnapshotOptions": "value", + "GraphitiMcpClientLike": "value", + "GraphitiMemoryAdapterOptions": "value", + "GraphitiToolNames": "value", + "Mem0ClientMode": "value", + "Mem0HostedClient": "value", + "Mem0HostedMemoryAdapterOptions": "value", + "Mem0MemoryAdapterOptions": "value", + "Mem0OssClient": "value", + "Mem0OssMemoryAdapterOptions": "value", + "MemoryConfigScenario": "type", + "Neo4jAgentMemoryAdapterOptions": "value", + "OwnedAgentMemoryRunLease": "value", + "RetrievalHoldoutBypassReason": "value", + "RetrievalHoldoutCallContext": "value", + "RetrievalHoldoutConfig": "value", + "RetrievalHoldoutEligibleItem": "value", + "RetrievalHoldoutEvent": "value", + "RetrievalHoldoutOffPolicyOptions": "value", + "RetrievalHoldoutOffPolicyResult": "value", + "RetrievalHoldoutResult": "value", + "RetrievalHoldoutSessionState": "value", + "RetrievalHoldoutSessionSummary": "value", + "RunAgentMemoryExperimentOptions": "type", + "RunAgentMemoryExperimentResult": "type", + "RunAgentMemoryImprovementOptions": "type", + "RunAgentMemoryImprovementResult": "type", + "RunAgentMemoryLearningExperimentOptions": "type", + "RunAgentMemoryLearningExperimentResult": "type", + "acquireAgentMemoryRunLease": "value", + "agentMemorySequenceJudge": "value", + "applyRetrievalHoldout": "value", + "applySessionStickyRetrievalHoldout": "value", + "buildAgentMemorySequenceScenarios": "value", + "buildAgentMemorySequencesFromBenchmarkCases": "value", + "compareAgentMemoryLearning": "value", + "createAgentMemoryBranch": "value", + "createGraphitiMemoryAdapter": "value", + "createMem0MemoryAdapter": "value", + "createMemoryExecutionPool": "value", + "createNeo4jAgentMemoryAdapter": "value", + "defaultGetMemoryContext": "value", + "deterministicRng": "value", + "emitRetrievalHoldoutBypass": "value", + "forkAgentMemoryBranchSnapshot": "value", + "graphitiMemoryAdapterIdentity": "value", + "mem0MemoryAdapterIdentity": "value", + "memoryHitToSourceRecord": "value", + "memoryRecoveryDelayMs": "value", + "memoryWriteResultToSourceRecord": "value", + "renderMemoryContext": "value", + "resetRetrievalHoldoutRegistry": "value", + "resolveMemoryCleanupTimeoutMs": "value", + "retrievalHoldoutConfigHash": "value", + "runAgentMemoryExperiment": "value", + "runAgentMemoryImprovement": "value", + "runAgentMemoryLearningExperiment": "value", + "runBoundedMemoryLifecycle": "value", + "sleepForMemoryRecovery": "value", + "toOffPolicyTrajectory": "value" + }, + "./sources": { + "CornellLiiSelector": "value", + "CornellLiiSourceOptions": "value", + "FetchOpts": "value", + "FragmentProvenance": "value", + "IRS_DIMENSION_HINTS": "value", + "IrsPublicationsSourceOptions": "value", + "KnowledgeFragment": "value", + "KnowledgeSource": "value", + "MAX_RESPONSE_BYTES": "value", + "MIN_REQUEST_GAP_MS": "value", + "POLITE_USER_AGENT": "value", + "PoliteFetchOptions": "value", + "PoliteFetchResult": "value", + "StateSosEntity": "value", + "StateSosSourceConfig": "value", + "__resetHttpThrottle": "value", + "createCornellLiiSource": "value", + "createIrsPublicationsSource": "value", + "createStateSosSource": "value", + "extractLinks": "value", + "firstMatch": "value", + "htmlToText": "value", + "innerHtmlById": "value", + "looksLikeBlockPage": "value", + "politeFetch": "value" + }, + "./viz": { + "KnowledgeCommunity": "value", + "KnowledgeGap": "value", + "KnowledgeVizEdge": "value", + "KnowledgeVizGraph": "value", + "KnowledgeVizNode": "value", + "SurprisingConnection": "value", + "detectKnowledgeGaps": "value", + "findSurprisingConnections": "value", + "toKnowledgeVizGraph": "value" + } + }, + "assets": [] +} diff --git a/package.json b/package.json index de58083..f581ade 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,11 @@ "lint": "biome check src tests", "format": "biome format --write src tests", "check:skills": "node scripts/check-skills.mjs", - "verify:package": "pnpm run check:skills && publint && attw --pack --profile esm-only . && node scripts/verify-package.mjs", - "verify:official-optimizers": "node scripts/verify-official-optimizers.mjs" + "verify:package": "pnpm run check:version-bump && pnpm run check:skills && pnpm run check:api-surface && publint && attw --pack --profile esm-only . && node scripts/verify-package.mjs", + "verify:official-optimizers": "node scripts/verify-official-optimizers.mjs", + "check:version-bump": "node scripts/check-version-bump.mjs", + "check:api-surface": "node scripts/check-api-surface.mjs", + "api:surface": "node scripts/check-api-surface.mjs --write" }, "dependencies": { "@types/proper-lockfile": "4.1.4", @@ -91,11 +94,13 @@ "@tangle-network/agent-interface": "1.0.0", "@types/node": "^26.1.1", "mem0ai": "3.1.2", + "oxc-parser": "0.144.0", "publint": "^0.3.22", "tsdown": "^0.22.14", "typescript": "^7.0.2", "vite": "8.1.5", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "yaml": "2.9.0" }, "pnpm": { "ignoredBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8ff291..4d0a8fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: mem0ai: specifier: 3.1.2 version: 3.1.2(@cloudflare/workers-types@4.20260702.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.11.1)(compromise@14.16.0)(mongodb@7.5.0)(natural@8.1.1(@opentelemetry/api@1.9.1))(pg@8.11.3) + oxc-parser: + specifier: 0.144.0 + version: 0.144.0 publint: specifier: ^0.3.22 version: 0.3.22 @@ -61,6 +64,9 @@ importers: vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + yaml: + specifier: 2.9.0 + version: 2.9.0 packages: @@ -372,12 +378,137 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.144.0': + resolution: {integrity: sha512-IaoGBEp/huvja99PxI/b72TbKFzA/UzxxAka7f233dc/Tg/rRTX9Qn8IquFLWwWf4IddN/5TaJ8S4Subbjq7wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.144.0': + resolution: {integrity: sha512-u6fJu8XQXP99+9pYO3jq7F1D7V9fyFuDBShYFlr+gY+GcJzhveeN/zoMfuXxX6XBquJO0kjqKd7BjhJ7pClWXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.144.0': + resolution: {integrity: sha512-o9xGSmMQcboJLjwI+acFf6xa7nYdp0/nRFE8ry4Xrt8OviQ9ITFDBUkAXVJMOLchSV9Pu981GxJuW0mt4i6vQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.144.0': + resolution: {integrity: sha512-2yNm4tX++W3KLbyziVhs5alSb74a3C1uNDu/1P/AQj1ux8yZYuvbCAeJCCrGkr8J18ZmnBAzDthdTZBEAEb71w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.144.0': + resolution: {integrity: sha512-TG4CjY1OjynplkF9nAQ9m9zboPJksnbAF+U/9xQGSXyIt+5sQRitwfQrUgjrG17/up9G8k/boNjLD2zp4xq1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.144.0': + resolution: {integrity: sha512-i0T9NagVmqc+rbSyBr5mDKj7TCMIBRrSteQlQJt1WhWIH/sZeOP9GB09H9w98YdinuZkDIPmO7Fz0jDC7bMvSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.144.0': + resolution: {integrity: sha512-YUsEqM3WMS3mOON+TFf7RzS0QthzEifx7tpUQu0GSF2MsT+D6t154ZBs6WhWaCZNl0GuVDEvndCyEAUBHzSHGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.144.0': + resolution: {integrity: sha512-LlWH4kt+IET3qIAe0e0IFLNlQ3CVUAfN//UFsA6N0/FghMh/FBk1e+wzvgG+t8WSnXkvf8B1TovquS2EJras9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.144.0': + resolution: {integrity: sha512-ajXbXIWBWUD4U3IQxr2p6DiXwD7GPHEBLa+JteKhIfvLmBEBdTjO28lP+5r3AF2qal8cxLERfTnGs64Z22ZuXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.144.0': + resolution: {integrity: sha512-/+sDzL/4cWEwdqenKo/DX3gkkxu7H7ytFAtealDey/Gd59yPWn64obVk6wXKVjVfXMciUUUTySxZG9AIMX3RNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.144.0': + resolution: {integrity: sha512-dMVhPBbrd8y6aeLd7Ihn9OZhKO8QgCQVtLBTRgbmf4lKrcR61SpaQRJPJuocTc/Cn5SJMm+alHYPnzkbOGM7Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.144.0': + resolution: {integrity: sha512-jQ8O0+b6J2IhJgm0DnqEJq8hG9OocmF1b4TBWCk08CRWqTmLZj/+lYs7w3OA60nb2SiqOmthQyJPacrCi7y+oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.144.0': + resolution: {integrity: sha512-/mZxZtcGrzuvqPLPV7gjavbROYs/dHy6+yQ2Sl/2to/+qoC/v6CcruGFnfQPzQbXXTYReXJzLb5QY9KmgCbJOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.144.0': + resolution: {integrity: sha512-/caRGFHcarHZlBrucBwQwBbzqhD+UfZZ/r7soocS0/mp6/5KTq+1Zl/OQx5lFLcN+GpUPYszbrvQU9MCFLEzJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.144.0': + resolution: {integrity: sha512-qFtwAo6BWuWDjh57QDdZdYi746GW0mIeoZSGK2jJqlxIjo389Y/7lrriTOI+ou7tTvusOrSYGQZ+e+nDswt2vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.144.0': + resolution: {integrity: sha512-n+NgMGWWEYpH+rlkMhDvLR2k8vJDHQp3j8SoS86IS6J0hc4kuDaiYAAvu9dF86xjeGYy+h9WLj12sylmBJV9sg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.144.0': + resolution: {integrity: sha512-fShxpJiCBOdG4+jBAvahTTFUDI5djXc/+IPC1ldeC8LbyCW0h9m/7oP8DRZWI7WT2Ahv8sHtZz4ugECylCFpTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.144.0': + resolution: {integrity: sha512-vFrYV+C3lJhIiSdNhdkZHnZ0YIClgTSluXaPMYjlGslVPD+uJg6K1s2xNL/X/gdBcy9IIbjbp0vNBwQhdMMdkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.144.0': + resolution: {integrity: sha512-0ASbKSwdeihMekyy7y4jC0CwW3XBDZk5Sw64m/W7IReVQHaduqLYssF9KCJA2oHG9oldnl/1CMxqCoImXfqQkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} '@oxc-project/types@0.140.0': resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@publint/pack@0.1.6': resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} engines: {node: '>=18'} @@ -1804,6 +1935,10 @@ packages: openapi3-ts@4.6.0: resolution: {integrity: sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==} + oxc-parser@0.144.0: + resolution: {integrity: sha512-eacM4wMgGWXctHubY262yo+50E76qtQBqe+uK73YEV1IT3qP12Acbnf9Nc8t+agIAdnko9iVT4KF83/d0EjY5w==} + engines: {node: ^20.19.0 || >=22.12.0} + package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -2645,10 +2780,69 @@ snapshots: '@opentelemetry/api@1.9.1': optional: true + '@oxc-parser/binding-android-arm-eabi@0.144.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.144.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.144.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.144.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.144.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.144.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.144.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.144.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.144.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.144.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.144.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.144.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.144.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.144.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.144.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.144.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.144.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.144.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.144.0': + optional: true + '@oxc-project/types@0.139.0': {} '@oxc-project/types@0.140.0': {} + '@oxc-project/types@0.144.0': {} + '@publint/pack@0.1.6': dependencies: tinyexec: 1.2.4 @@ -3737,6 +3931,30 @@ snapshots: dependencies: yaml: 2.9.0 + oxc-parser@0.144.0: + dependencies: + '@oxc-project/types': 0.144.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.144.0 + '@oxc-parser/binding-android-arm64': 0.144.0 + '@oxc-parser/binding-darwin-arm64': 0.144.0 + '@oxc-parser/binding-darwin-x64': 0.144.0 + '@oxc-parser/binding-freebsd-x64': 0.144.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.144.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.144.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.144.0 + '@oxc-parser/binding-linux-arm64-musl': 0.144.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.144.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.144.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.144.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.144.0 + '@oxc-parser/binding-linux-x64-gnu': 0.144.0 + '@oxc-parser/binding-linux-x64-musl': 0.144.0 + '@oxc-parser/binding-openharmony-arm64': 0.144.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.144.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.144.0 + '@oxc-parser/binding-win32-x64-msvc': 0.144.0 + package-manager-detector@1.8.0: {} packet-reader@1.0.0: {} diff --git a/scripts/check-api-surface.mjs b/scripts/check-api-surface.mjs new file mode 100644 index 0000000..edb5d94 --- /dev/null +++ b/scripts/check-api-surface.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * Record — and hold to — the symbols each published entry point exports. + * + * Reads the BUILT declaration files, because those are what npm puts in the + * tarball and what a consumer's compiler reads. Source is not consulted: a + * symbol that source exports but the build drops is not on the surface, and the + * defect being closed is about what a consumer can actually resolve. + * + * The parser is `oxc-parser`, not the TypeScript compiler API, and that is a + * requirement rather than a preference. This repository set spans TypeScript 6 + * and TypeScript 7, and TypeScript 7 ships no JavaScript compiler API at all — + * `ts.createProgram` is undefined there. A checker-based extractor would work + * in one repository and have to be rewritten in the others, which is how three + * copies of one rule end up with three different bugs. `oxc-parser` presents + * the same API on every repository and parses the declaration dialect the + * build already emits through the same parser family. + * + * Usage: + * node scripts/check-api-surface.mjs [--write] [packageDirectory...] + * + * Default package directory is the repository root. `--write` regenerates the + * records; without it the command compares and fails on any difference. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseSync } from 'oxc-parser' +import { + compareSurfaces, + formatSurface, + relativeToRoot, + resolveExportEntries, + surfaceRecordPath, +} from './lib/api-surface.mjs' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +const args = process.argv.slice(2) +const write = args.includes('--write') +const packageDirectories = args.filter((argument) => !argument.startsWith('--')) +if (packageDirectories.length === 0) packageDirectories.push('.') + +/** Declaration kind of a top-level declaration node. */ +const declarationKind = (node) => { + switch (node.type) { + case 'TSInterfaceDeclaration': + case 'TSTypeAliasDeclaration': + return 'type' + case 'ClassDeclaration': + case 'FunctionDeclaration': + case 'TSDeclareFunction': + case 'TSEnumDeclaration': + case 'TSModuleDeclaration': + case 'VariableDeclaration': + return 'value' + default: + throw new Error(`unhandled exported declaration node ${node.type}`) + } +} + +const declaredNames = (node) => { + if (node.type === 'VariableDeclaration') { + return node.declarations.map((declarator) => declarator.id.name) + } + if (node.type === 'TSModuleDeclaration') { + return [node.id.type === 'Identifier' ? node.id.name : node.id.value] + } + return [node.id.name] +} + +/** + * Resolve a relative specifier written with the runtime extension back to the + * declaration file that states its types, the way a bundler-mode compiler does. + */ +const resolveRelativeDeclaration = (fromFile, specifier) => { + const base = resolve(dirname(fromFile), specifier) + const candidates = [ + base.replace(/\.m?js$/, '.d.ts'), + base.replace(/\.m?js$/, '.d.mts'), + `${base}.d.ts`, + base, + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + throw new Error(`cannot resolve "${specifier}" from ${fromFile} to a declaration file`) +} + +/** + * Every symbol `file` exports, name to kind. Follows `export * from` into the + * chunk files the build splits declarations across, because those re-exports + * are what a consumer's entry point actually resolves through. + */ +const exportsOfFile = (file, seen = new Set()) => { + if (seen.has(file)) return {} + seen.add(file) + const source = readFileSync(file, 'utf8') + const parsed = parseSync(file, source, { lang: 'dts' }) + if (parsed.errors.length > 0) { + throw new Error(`${file} failed to parse: ${parsed.errors[0].message}`) + } + const names = {} + for (const node of parsed.program.body) { + if (!node.type.startsWith('Export')) continue + switch (node.type) { + case 'ExportNamedDeclaration': { + if (node.declaration) { + const kind = declarationKind(node.declaration) + for (const name of declaredNames(node.declaration)) names[name] = kind + break + } + for (const specifier of node.specifiers) { + const exported = specifier.exported + const name = exported.type === 'Identifier' ? exported.name : exported.value + // `export type { A }` marks the statement; `export { type A }` marks + // the specifier. Either one makes the name type-only. + names[name] = node.exportKind === 'type' || specifier.exportKind === 'type' ? 'type' : 'value' + } + break + } + case 'ExportAllDeclaration': { + const specifier = node.source.value + if (!specifier.startsWith('.')) { + throw new Error( + `${file} re-exports all of "${specifier}", which is outside this package. ` + + 'The surface record cannot state symbols it cannot read; ' + + 'name the re-exported symbols explicitly instead.', + ) + } + const target = resolveRelativeDeclaration(file, specifier) + if (node.exported) { + const name = node.exported.type === 'Identifier' ? node.exported.name : node.exported.value + names[name] = 'value' + break + } + for (const [name, kind] of Object.entries(exportsOfFile(target, seen))) { + names[name] = node.exportKind === 'type' ? 'type' : kind + } + break + } + case 'ExportDefaultDeclaration': + names.default = 'value' + break + default: + throw new Error(`${file} uses unhandled export form ${node.type}`) + } + } + return names +} + +const surfaceOfPackage = (packageDirectory) => { + const packageDir = resolve(repoRoot, packageDirectory) + const manifestPath = resolve(packageDir, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const { entries, assets } = resolveExportEntries(manifest, packageDir) + const missing = entries.filter((entry) => !existsSync(entry.file)) + if (missing.length > 0) { + throw new Error( + `${relativeToRoot(repoRoot, manifestPath)} declares entry points whose declarations are ` + + `not built: ${missing.map((entry) => entry.subpath).join(', ')}. Build the package first.`, + ) + } + const recorded = {} + for (const entry of entries) recorded[entry.subpath] = exportsOfFile(entry.file) + return { package: manifest.name, entries: recorded, assets } +} + +const failures = [] +const reports = [] + +for (const packageDirectory of packageDirectories) { + const recordPath = resolve(repoRoot, surfaceRecordPath(packageDirectory.replace(/^\.\/?/, ''))) + const surface = surfaceOfPackage(packageDirectory) + const generated = formatSurface(surface) + const total = Object.values(surface.entries).reduce( + (sum, names) => sum + Object.keys(names).length, + 0, + ) + const displayPath = relativeToRoot(repoRoot, recordPath) + if (write) { + writeFileSync(recordPath, generated) + reports.push(`${displayPath}: wrote ${total} exports across ${Object.keys(surface.entries).length} entry points`) + continue + } + const committed = existsSync(recordPath) ? readFileSync(recordPath, 'utf8') : null + if (committed === null) { + failures.push( + `${displayPath} does not exist. Every publishable package records its export surface ` + + 'so a change to it can be required to carry a version bump.', + ) + continue + } + if (committed !== generated) { + const changes = compareSurfaces(JSON.parse(committed), surface) + const lines = [`${displayPath} does not match the built declarations.`] + for (const [label, list] of [ + ['not recorded (present in the build)', changes.added], + ['recorded but gone from the build', changes.removed], + ['kind narrowed', changes.narrowed], + ['kind changed', changes.widened], + ]) { + for (const change of list.slice(0, 20)) lines.push(` ${label}: ${change}`) + if (list.length > 20) lines.push(` ${label}: ...and ${list.length - 20} more`) + } + failures.push(lines.join('\n')) + continue + } + reports.push(`${displayPath}: ${total} exports across ${Object.keys(surface.entries).length} entry points, record current`) +} + +if (failures.length > 0) { + process.stderr.write( + `${[ + 'The export surface record is out of date.', + '', + ...failures, + '', + 'Regenerate it in the same change:', + ` pnpm run build && pnpm run api:surface`, + '', + 'The record is what check:version-bump compares against the merge base, so a stale', + 'record lets a new export ship under a version the registry already holds.', + ].join('\n')}\n`, + ) + process.exit(1) +} + +process.stdout.write(`${reports.join('\n')}\n`) diff --git a/scripts/check-version-bump.mjs b/scripts/check-version-bump.mjs new file mode 100644 index 0000000..dbda449 --- /dev/null +++ b/scripts/check-version-bump.mjs @@ -0,0 +1,511 @@ +#!/usr/bin/env node +/** + * Fail a change that alters a consumer-visible package surface without bumping + * that package's version to a higher one. + * + * The defect this closes: a pull request moves a peer range or a catalog pin, + * merges, and main now declares a version the registry already holds under + * DIFFERENT, consumer-visible terms. `publish.yml` skips a version already on + * the registry, so re-tagging cannot correct it — the change simply never + * reaches a consumer, and nothing goes red. It happened to 0.119.0, whose + * agent-eval peer floor moved from `>=0.139.2` to `>=0.140.1` with no bump, and + * unnoticed for far longer to `@tangle-network/agent-bench`. + * + * The rule: for every publishable manifest in the workspace, if any field npm + * copies into the published manifest differs from the merge base, `version` must + * be strictly higher than the base's. `version` is not part of the compared + * surface — it is the payment a surface change is made with, and a payment in + * the wrong direction lands on a version the registry may already hold. + * + * `catalog:` specifiers are compared by what they RESOLVE to, through + * `pnpm-workspace.yaml`. That indirection is how the drift stayed invisible: + * `"@tangle-network/agent-knowledge": "catalog:"` is byte-identical across the + * change that moved the installed version 7.0.3 -> 7.0.4. Only the resolved + * version is compared, so moving a dependency between the default catalog, a + * named catalog, and a literal pin is silent as long as the version is the same. + * + * Two deliberate scope boundaries, so neither reads as an oversight: + * + * - `workspace:` specifiers are NOT resolved. One resolves to a sibling in the + * same commit, so it moves only when that sibling's version moves — which + * this check already requires to be paid for, on that sibling. Resolving it + * here would demand a second bump for a change already accounted. + * - Built output is not compared. A `src/` change that rewrites all of + * `dist/` is allowed with no bump; releases, not this check, decide when + * code ships. The EXPORTED SYMBOLS are the one exception, below. + * + * The manifest is only half of what a consumer sees. Adding an export to an + * entry point changes no manifest field at all — `exports["./durable"].types` + * still reads `./dist/durable.d.ts` — so the manifest comparison reports + * "consumer surface unchanged" and the new symbol ships under a version the + * registry already holds. That happened to `PursuitProjection`, + * `PursuitRunProjection` and `PursuitNodeProjection`: added and exported at + * 0.140.0, which npm already held, so they resolved in no published version and + * a consumer spent hours proving types that existed were absent. + * + * So every publishable package also commits `api-surface.json`, the symbols each + * entry point exports, generated from the built declarations by + * `check-api-surface.mjs`. This check compares that record against the merge + * base and requires the level the change implies: + * + * - a removed export, or one narrowed from a value to a type, is breaking; + * - an added export is additive; + * - a manifest-only change asks for a higher version, as it always did. + * + * The level each severity demands is read off the consumer's compatibility + * boundary, which sits one position further right below 1.0 — see + * `requiredBumpLevel` in `lib/api-surface.mjs`. + * + * This file, `check-api-surface.mjs` and `lib/api-surface.mjs` are kept + * byte-identical in agent-eval, agent-knowledge and agent-runtime. They read + * everything repo-specific out of the manifests they inspect, so an edit to one + * belongs in all three. Nothing here may name a single repository. + * + * Usage: pnpm run check:version-bump + * PACKAGE_VERSION_BUMP_BASE base ref to compare against (default: the + * CI base branch, else origin/main, else main) + * PACKAGE_VERSION_BUMP_ROOT repository to inspect (default: this repo) + */ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse as parseYaml } from 'yaml' +import { + LEVEL_RANK, + compareSurfaces, + requiredBumpLevel, + surfaceRecordPath, + surfaceSeverity, + versionBumpLevel, +} from './lib/api-surface.mjs' + +const repoRoot = resolve( + process.env.PACKAGE_VERSION_BUMP_ROOT ?? + resolve(dirname(fileURLToPath(import.meta.url)), '..'), +) + +/** + * Every field npm copies into the published manifest, so every field a consumer + * can read or resolve against. `version` is absent on purpose (see the header); + * so are `scripts` as a whole, `devDependencies`, and metadata prose, none of + * which change what a consumer installs. The install-lifecycle scripts are the + * exception and are compared separately — they execute on a consumer's machine. + */ +const CONSUMER_VISIBLE_FIELDS = [ + 'name', + 'private', + 'type', + 'main', + 'module', + 'types', + 'typings', + 'browser', + 'exports', + 'imports', + 'typesVersions', + 'bin', + 'files', + 'directories', + 'engines', + 'os', + 'cpu', + 'license', + 'sideEffects', + 'publishConfig', + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'bundleDependencies', + 'bundledDependencies', + 'installScripts', +] + +/** Fields whose values are `{ [dependency]: specifier }` and so carry `catalog:` indirection. */ +const SPECIFIER_FIELDS = new Set(['dependencies', 'optionalDependencies', 'peerDependencies']) + +/** Fields npm treats as an unordered set, where a reordering changes nothing. */ +const UNORDERED_FIELDS = new Set(['files', 'os', 'cpu']) + +/** The scripts npm runs on the CONSUMER's machine when the tarball is installed. */ +const INSTALL_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall'] + +const git = (args, { allowFailure = false } = {}) => { + try { + return execFileSync('git', ['-C', repoRoot, ...args], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (error) { + if (allowFailure) return null + throw new Error(`git ${args.join(' ')} failed: ${error.stderr || error.message}`) + } +} + +const fileAtRef = (ref, path) => git(['show', `${ref}:${path}`], { allowFailure: true }) + +const resolveBase = () => { + const configured = + process.env.PACKAGE_VERSION_BUMP_BASE || + // The base BRANCH, never the webhook's frozen base sha. On a pull_request + // run the checked-out merge ref is recomputed against the CURRENT base tip, + // so the frozen sha can name a commit that is no longer the merge parent — + // and then a release someone else merged in the meantime pays for a change + // in this pull request. + (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : '') + // Fail closed on any event that HAS a base branch, BEFORE any fallback. A + // fallback would compare against whatever `main` happens to be in the clone, + // which is not the branch under review — a silent pass with no signal. A push + // or manual run genuinely has no base and is not what this gates. + const baselessEvents = new Set(['push', 'workflow_dispatch', 'schedule']) + if ( + !configured && + process.env.GITHUB_ACTIONS && + !baselessEvents.has(process.env.GITHUB_EVENT_NAME ?? '') + ) { + throw new Error( + `running in CI on a ${process.env.GITHUB_EVENT_NAME ?? 'unknown'} event with no base ` + + 'branch to compare against. Set GITHUB_BASE_REF (or PACKAGE_VERSION_BUMP_BASE).', + ) + } + const candidates = configured ? [configured] : ['origin/main', 'main'] + for (const candidate of candidates) { + const resolved = git(['rev-parse', '--verify', `${candidate}^{commit}`], { allowFailure: true }) + if (resolved) return { ref: resolved.trim(), label: candidate } + } + if (configured) { + throw new Error( + `cannot resolve the requested base commit ${configured}. ` + + 'Check out with fetch-depth: 0 so the base is present.', + ) + } + return null +} + +const globToRegExp = (glob) => { + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&') + const body = escaped.replace(/\*\*/g, '').replace(/\*/g, '[^/]*').replace(//g, '.*') + return new RegExp(`^${body}$`) +} + +/** pnpm workspace `packages:` patterns, including `!` exclusions and `**`. */ +const workspaceMatcher = (patterns) => { + const include = [] + const exclude = [] + for (const pattern of patterns) { + if (typeof pattern !== 'string') continue + const normalized = pattern.replace(/^\.\//, '').replace(/\/+$/, '') + if (normalized.startsWith('!')) exclude.push(globToRegExp(normalized.slice(1))) + else include.push(globToRegExp(normalized)) + } + return (directory) => + include.some((pattern) => pattern.test(directory)) && + !exclude.some((pattern) => pattern.test(directory)) +} + +const workspaceAtRef = (ref) => { + const raw = fileAtRef(ref, 'pnpm-workspace.yaml') + if (raw === null) return { catalogs: { default: {}, named: {} }, matches: () => false } + const document = parseYaml(raw) ?? {} + return { + catalogs: { default: document.catalog ?? {}, named: document.catalogs ?? {} }, + matches: workspaceMatcher(document.packages ?? []), + } +} + +/** + * Every manifest that could be published from this ref, keyed by package NAME. + * Keyed by name, not path, so moving a package between directories still + * compares against what that same package name already published. + */ +const manifestsAtRef = (ref, workspace) => { + const tracked = (git(['ls-tree', '-r', '--name-only', ref]) ?? '') + .split('\n') + .filter((path) => path.endsWith('package.json') && !path.includes('node_modules/')) + const byName = new Map() + for (const path of tracked) { + const directory = path === 'package.json' ? '' : dirname(path) + if (directory !== '' && !workspace.matches(directory)) continue + const raw = fileAtRef(ref, path) + if (raw === null) continue + let manifest + try { + manifest = JSON.parse(raw) + } catch (error) { + throw new Error(`${path} at ${ref.slice(0, 12)} is not valid JSON: ${error.message}`) + } + if (typeof manifest?.name !== 'string') continue + byName.set(manifest.name, { path, manifest }) + } + return byName +} + +/** + * What a consumer actually resolves. Only the RESOLVED version is returned, so + * moving a dependency between the default catalog, a named catalog, and a + * literal pin is silent when the version does not move. + */ +const resolveSpecifier = (specifier, catalogs, context) => { + if (typeof specifier !== 'string' || !specifier.startsWith('catalog:')) return specifier + const name = specifier.slice('catalog:'.length).trim() + const table = name === '' ? catalogs.default : catalogs.named[name] + const resolved = table?.[context.dependency] + if (resolved === undefined) { + // Fail closed: an unresolvable catalog pin must not compare equal by accident. + throw new Error( + `${context.path} ${context.field}.${context.dependency} is "${specifier}" but ` + + `pnpm-workspace.yaml has no ${name === '' ? 'catalog' : `catalogs.${name}`} entry for it`, + ) + } + return String(resolved) +} + +const surfaceOf = ({ path, manifest }, catalogs) => { + const surface = {} + const withInstallScripts = { + ...manifest, + installScripts: Object.fromEntries( + INSTALL_LIFECYCLE_SCRIPTS.filter((name) => manifest.scripts?.[name] !== undefined).map( + (name) => [name, manifest.scripts[name]], + ), + ), + } + for (const field of CONSUMER_VISIBLE_FIELDS) { + if (!(field in withInstallScripts)) continue + const value = withInstallScripts[field] + if (field === 'installScripts' && Object.keys(value).length === 0) continue + if (SPECIFIER_FIELDS.has(field) && value && typeof value === 'object') { + surface[field] = Object.fromEntries( + Object.entries(value).map(([dependency, specifier]) => [ + dependency, + resolveSpecifier(specifier, catalogs, { path, field, dependency }), + ]), + ) + } else if (UNORDERED_FIELDS.has(field) && Array.isArray(value)) { + // npm reads these as sets. Negations are order-sensitive, so only sort + // when none is present. + surface[field] = value.some((entry) => typeof entry === 'string' && entry.startsWith('!')) + ? value + : [...value].sort() + } else { + surface[field] = value + } + } + return surface +} + +const show = (value) => (value === undefined ? '(absent)' : JSON.stringify(value)) + +/** Report the changed leaves, so a failure names the exact edit to pay for. */ +const surfaceChanges = (before, after) => { + const changes = [] + for (const field of CONSUMER_VISIBLE_FIELDS) { + const left = before[field] + const right = after[field] + if (JSON.stringify(left) === JSON.stringify(right)) continue + const isMap = (value) => value && typeof value === 'object' && !Array.isArray(value) + if (isMap(left) && isMap(right)) { + for (const key of [...new Set([...Object.keys(left), ...Object.keys(right)])].sort()) { + if (JSON.stringify(left[key]) === JSON.stringify(right[key])) continue + changes.push(`${field}.${key}: ${show(left[key])} -> ${show(right[key])}`) + } + } else { + changes.push(`${field}: ${show(left)} -> ${show(right)}`) + } + } + return changes +} + +/** + * The export surface record of one package at one ref, or `null` when the ref + * carries no record for it. + */ +const surfaceAtRef = (ref, manifestPath) => { + const recordPath = surfaceRecordPath(manifestPath === 'package.json' ? '' : dirname(manifestPath)) + const raw = fileAtRef(ref, recordPath) + if (raw === null) return null + try { + return { path: recordPath, surface: JSON.parse(raw) } + } catch (error) { + throw new Error(`${recordPath} at ${ref.slice(0, 12)} is not valid JSON: ${error.message}`) + } +} + +/** Rank a level, treating `lower` and `unorderable` as paying nothing. */ +const rankOf = (level) => LEVEL_RANK[level] ?? 0 + +const base = resolveBase() +if (base === null) { + process.stdout.write('No base ref to compare against; version-bump check does not apply.\n') + process.exit(0) +} + +const head = git(['rev-parse', 'HEAD']).trim() +const mergeBase = git(['merge-base', base.ref, head], { allowFailure: true })?.trim() +if (!mergeBase) { + throw new Error(`${base.label} (${base.ref.slice(0, 12)}) shares no history with HEAD`) +} + +const baseLabel = `${base.label} (${mergeBase.slice(0, 12)})` + +if (mergeBase === head) { + process.stdout.write(`No commits ahead of ${baseLabel}; nothing to compare for a version bump.\n`) + process.exit(0) +} + +const baseWorkspace = workspaceAtRef(mergeBase) +const headWorkspace = workspaceAtRef(head) +const baseManifests = manifestsAtRef(mergeBase, baseWorkspace) +const headManifests = manifestsAtRef(head, headWorkspace) + +const publishable = (entry) => entry !== undefined && entry.manifest.private !== true +const failures = [] +const inspected = [] + +// The union: a package that STOPS being publishable is itself a consumer-visible +// change, so it cannot be dropped from the comparison by the flag that hides it. +const names = [ + ...new Set( + [...baseManifests.entries(), ...headManifests.entries()] + .filter(([name]) => publishable(baseManifests.get(name)) || publishable(headManifests.get(name))) + .map(([name]) => name), + ), +].sort() + +for (const name of names) { + const baseEntry = baseManifests.get(name) + const headEntry = headManifests.get(name) + if (!headEntry) { + inspected.push(`${name}: removed from the workspace; nothing left to version`) + continue + } + if (!baseEntry) { + inspected.push(`${headEntry.path}: new publishable package at ${headEntry.manifest.version}`) + continue + } + + const changes = surfaceChanges( + surfaceOf(baseEntry, baseWorkspace.catalogs), + surfaceOf(headEntry, headWorkspace.catalogs), + ) + + const baseRecord = surfaceAtRef(mergeBase, baseEntry.path) + const headRecord = surfaceAtRef(head, headEntry.path) + if (headRecord === null) { + // Fail closed rather than pass a package whose symbols nothing states. A + // missing record is indistinguishable from a surface that never changes. + failures.push({ + path: headEntry.path, + name, + baseVersion: baseEntry.manifest.version, + headVersion: headEntry.manifest.version, + changes, + missingRecord: surfaceRecordPath( + headEntry.path === 'package.json' ? '' : dirname(headEntry.path), + ), + }) + continue + } + // A record appearing for the first time states the surface as it already is, + // so there is nothing yet to have changed against. + const exportChanges = + baseRecord === null + ? { added: [], removed: [], narrowed: [], widened: [] } + : compareSurfaces(baseRecord.surface, headRecord.surface) + const severity = surfaceSeverity(exportChanges) + const exportLines = [ + ...exportChanges.removed.map((change) => `export removed: ${change}`), + ...exportChanges.narrowed.map((change) => `export narrowed: ${change}`), + ...exportChanges.added.map((change) => `export added: ${change}`), + ...exportChanges.widened.map((change) => `export kind changed: ${change}`), + ] + + if (changes.length === 0 && exportLines.length === 0) { + inspected.push(`${headEntry.path}: consumer surface unchanged at ${headEntry.manifest.version}`) + continue + } + + // The manifest rule asks only for a higher version; the export rule asks for a + // level. The stronger of the two governs. + const requiredLevel = (() => { + const forExports = requiredBumpLevel(severity, baseEntry.manifest.version) + const forManifest = changes.length > 0 ? 'patch' : 'none' + return rankOf(forExports) >= rankOf(forManifest) ? forExports : forManifest + })() + const paidLevel = versionBumpLevel(baseEntry.manifest.version, headEntry.manifest.version) + + if (rankOf(paidLevel) >= rankOf(requiredLevel)) { + inspected.push( + `${headEntry.path}: ${changes.length} manifest and ${exportLines.length} export change(s) ` + + `needing a ${requiredLevel} bump, paid for by ` + + `${baseEntry.manifest.version} -> ${headEntry.manifest.version} (${paidLevel})`, + ) + continue + } + failures.push({ + path: headEntry.path, + name, + baseVersion: baseEntry.manifest.version, + headVersion: headEntry.manifest.version, + changes: [...changes, ...exportLines], + requiredLevel, + paidLevel, + severity, + }) +} + +if (failures.length > 0) { + const lines = ['A consumer-visible change must ship under a higher version.', ''] + for (const failure of failures) { + if (failure.missingRecord) { + lines.push( + `${failure.path} (${failure.name}) publishes entry points but ${failure.missingRecord} ` + + 'does not exist, so a change to its exported symbols cannot be seen here.', + ' Generate it with: pnpm run build && pnpm run api:surface', + '', + ) + continue + } + const versionState = (() => { + if (failure.baseVersion === failure.headVersion) return `still declares ${failure.headVersion}` + const move = `moves ${failure.baseVersion} -> ${failure.headVersion}` + return rankOf(failure.paidLevel) === 0 + ? `${move}, which is not higher` + : `${move}, only a ${failure.paidLevel} bump` + })() + const severityLabel = failure.severity === 'none' ? 'manifest-only' : failure.severity + lines.push( + `${failure.path} (${failure.name}) ${versionState}, but against ${baseLabel} it makes ` + + `${severityLabel === 'additive' ? 'an' : 'a'} ${severityLabel} change needing a ` + + `${failure.requiredLevel} bump:`, + ) + for (const change of failure.changes) lines.push(` ${change}`) + lines.push('') + } + // The command a repository actually uses to set a version, read from its own + // manifest rather than named here, so this file stays identical across repos. + const rootManifest = JSON.parse(fileAtRef(head, 'package.json') ?? '{}') + const bumpCommand = + rootManifest.scripts?.['release:prepare'] === undefined + ? ' set "version" in package.json' + : ' pnpm run release:prepare (root package)' + lines.push( + 'A version already on the registry cannot be corrected by re-tagging — publish.yml', + 'skips it — so this change would never reach a consumer.', + '', + 'Bump the version in the same pull request:', + bumpCommand, + 'and add the CHANGELOG entry that says what a consumer must do differently.', + ) + process.stderr.write(`${lines.join('\n')}\n`) + process.exit(1) +} + +process.stdout.write( + `${[ + `Consumer-visible package surfaces carry their version bumps (against ${baseLabel}).`, + ...inspected.map((line) => ` ${line}`), + ].join('\n')}\n`, +) diff --git a/scripts/lib/api-surface.mjs b/scripts/lib/api-surface.mjs new file mode 100644 index 0000000..44fbdf4 --- /dev/null +++ b/scripts/lib/api-surface.mjs @@ -0,0 +1,295 @@ +/** + * The public export surface of a package, and the version level a change to it + * must be paid for with. + * + * `check-version-bump.mjs` compares the MANIFEST — the fields npm copies into + * the published package.json. That comparison cannot see a symbol. Adding an + * export to a source entry point leaves every manifest field byte-identical: + * `exports["./durable"].types` still reads `./dist/durable.d.ts`, so the change + * is invisible and the package ships under a version the registry already + * holds. This module supplies the missing half — the SYMBOLS each entry point + * exports — as a committed record that a change has to update. + * + * The record is a file, not a live comparison of two builds. Three reasons: + * - the merge base's symbols are then one `git show` away, with no second + * checkout, no second install, and no second build; + * - the record appears in the pull request diff, so an added export is + * visible to a reviewer at the moment it is added; + * - `check-version-bump.mjs` keeps needing nothing but git, so it still runs + * before the build. + * + * A record can go stale. `check-api-surface.mjs` regenerates it from the built + * declaration files and fails when the two disagree, and it runs wherever a + * build has already happened. Staleness is therefore loud, never a silent pass. + * + * Scope: names and their kind (`value` or `type`), never the shape behind a + * name. Removing a field from an exported interface is a break this does not + * see. Deeper comparison would need a full type checker on both sides and would + * fail on every internal type edit; names and kinds are the granularity that + * matches the defect — a name that resolves in no published version. + */ +import { existsSync, readdirSync } from 'node:fs' +import { join, posix, relative, resolve } from 'node:path' + +/** Declaration-file suffixes an `exports` condition can legitimately point at. */ +const DECLARATION_SUFFIXES = ['.d.ts', '.d.mts', '.d.cts'] + +/** + * Conditions that resolve to the types a consumer compiles against, most + * specific first. `types` is authoritative; the others are read only when a + * package states no `types`, and are then required to be a declaration file + * before they count as an entry point. + */ +const TYPE_CONDITIONS = ['types', 'import', 'module', 'default', 'require'] + +const isDeclarationFile = (target) => DECLARATION_SUFFIXES.some((suffix) => target.endsWith(suffix)) + +/** + * The declaration target of one `exports` value. Returns `null` for an entry + * that resolves to something other than a declaration file — a JSON asset, a + * bare script — which is a real entry point with no symbols to record. + */ +const declarationTargetOf = (value) => { + if (typeof value === 'string') return isDeclarationFile(value) ? value : null + if (value === null || typeof value !== 'object') return null + if (Array.isArray(value)) { + for (const element of value) { + const target = declarationTargetOf(element) + if (target !== null) return target + } + return null + } + for (const condition of TYPE_CONDITIONS) { + if (!(condition in value)) continue + const target = declarationTargetOf(value[condition]) + if (target !== null) return target + } + return null +} + +/** + * Expand one `*` in a subpath against the files a build produced. + * + * `"./benchmarks/*": { "types": "./dist/benchmarks/*.d.ts" }` is a single + * manifest line standing for however many modules the build emitted. Each one + * is a separate entry point a consumer can import, so each is recorded + * separately — otherwise adding a benchmark module would be invisible here for + * the same reason adding a symbol is invisible to the manifest comparison. + */ +const expandWildcard = (subpath, target, packageDir) => { + const [prefix, suffix] = target.split('*') + // npm splits the pattern at the `*`, not at a path boundary: the text before + // the star is a directory path plus a possible filename prefix, and the star + // itself matches across `/`. Splitting with `dirname`/`basename` drops the + // final directory when the prefix ends in a separator, which silently matches + // nothing. + const separator = prefix.lastIndexOf('/') + const directory = resolve(packageDir, separator === -1 ? '.' : prefix.slice(0, separator)) + const namePrefix = separator === -1 ? prefix : prefix.slice(separator + 1) + const entries = [] + if (existsSync(directory)) { + const found = readdirSync(directory, { recursive: true, withFileTypes: true }) + const relativeNames = found + .filter((entry) => entry.isFile()) + .map((entry) => + posix.join(relative(directory, entry.parentPath).split('\\').join('/'), entry.name), + ) + .sort() + for (const name of relativeNames) { + if (!name.startsWith(namePrefix) || !name.endsWith(suffix)) continue + const stem = name.slice(namePrefix.length, name.length - suffix.length) + if (stem === '') continue + entries.push({ subpath: subpath.replace('*', stem), file: join(directory, name) }) + } + } + if (entries.length === 0) { + throw new Error( + `exports["${subpath}"] -> "${target}" matches no built file. Either the build did not ` + + 'emit the modules this pattern publishes, or the pattern is wrong. An entry point ' + + 'that resolves to nothing must not be recorded as an empty surface.', + ) + } + return entries +} + +/** + * Every entry point a consumer can import from this package, with the + * declaration file that states its symbols. + * + * Returns `assets` separately so an entry point with no declaration file is + * recorded as deliberately empty rather than dropped. A dropped entry would be + * a hole in the surface record with nothing naming it. + */ +export const resolveExportEntries = (manifest, packageDir) => { + const declared = manifest.exports + if (declared === undefined) return { entries: [], assets: [] } + const table = + typeof declared === 'string' || Array.isArray(declared) ? { '.': declared } : declared + const entries = [] + const assets = [] + for (const [subpath, value] of Object.entries(table)) { + // A condition name at the top level (`{ "import": ... }`) is sugar for a + // single "." entry, not a subpath. + if (!subpath.startsWith('.')) { + const target = declarationTargetOf(table) + if (target !== null) entries.push({ subpath: '.', file: resolve(packageDir, target) }) + else assets.push('.') + break + } + const target = declarationTargetOf(value) + if (target === null) { + assets.push(subpath) + continue + } + if (subpath.includes('*') || target.includes('*')) { + if (!subpath.includes('*') || !target.includes('*')) { + throw new Error( + `exports["${subpath}"] uses a pattern on one side only; ` + + 'a wildcard subpath and its target must both carry exactly one "*"', + ) + } + entries.push(...expandWildcard(subpath, target, packageDir)) + continue + } + entries.push({ subpath, file: resolve(packageDir, target) }) + } + entries.sort((left, right) => left.subpath.localeCompare(right.subpath)) + return { entries, assets: assets.sort() } +} + +/** Stable on-disk form: sorted everywhere, so a diff shows only real movement. */ +export const formatSurface = (surface) => { + const entries = {} + for (const subpath of Object.keys(surface.entries).sort()) { + const names = surface.entries[subpath] + entries[subpath] = Object.fromEntries(Object.keys(names).sort().map((name) => [name, names[name]])) + } + return `${JSON.stringify({ package: surface.package, entries, assets: [...surface.assets].sort() }, null, 2)}\n` +} + +/** + * What moved between two surface records. + * + * `value -> type` is listed as a narrowing: the name still type-checks, and the + * runtime binding a consumer imported is gone. That is a break the name set + * alone would call unchanged. + */ +export const compareSurfaces = (before, after) => { + const added = [] + const removed = [] + const narrowed = [] + const widened = [] + const subpaths = [ + ...new Set([...Object.keys(before.entries ?? {}), ...Object.keys(after.entries ?? {})]), + ].sort() + for (const subpath of subpaths) { + const left = before.entries?.[subpath] + const right = after.entries?.[subpath] + if (left === undefined) { + for (const name of Object.keys(right ?? {}).sort()) added.push(`${subpath} ${name}`) + continue + } + if (right === undefined) { + for (const name of Object.keys(left).sort()) removed.push(`${subpath} ${name}`) + continue + } + for (const name of [...new Set([...Object.keys(left), ...Object.keys(right)])].sort()) { + const from = left[name] + const to = right[name] + if (from === to) continue + if (from === undefined) added.push(`${subpath} ${name}`) + else if (to === undefined) removed.push(`${subpath} ${name}`) + else if (from === 'value' && to === 'type') narrowed.push(`${subpath} ${name}: value -> type`) + else widened.push(`${subpath} ${name}: ${from} -> ${to}`) + } + } + // An entry point that stops carrying declarations is a removal of everything + // a consumer could import through it, so it is compared with the same rule. + const beforeAssets = new Set(before.assets ?? []) + const afterAssets = new Set(after.assets ?? []) + for (const subpath of [...afterAssets].sort()) { + if (!beforeAssets.has(subpath) && before.entries?.[subpath] === undefined) { + added.push(`${subpath} (entry point, no declarations)`) + } + } + for (const subpath of [...beforeAssets].sort()) { + if (!afterAssets.has(subpath) && after.entries?.[subpath] === undefined) { + removed.push(`${subpath} (entry point, no declarations)`) + } + } + return { added, removed, narrowed, widened } +} + +export const surfaceSeverity = (changes) => { + if (changes.removed.length > 0 || changes.narrowed.length > 0) return 'breaking' + if (changes.added.length > 0 || changes.widened.length > 0) return 'additive' + return 'none' +} + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/ + +const parseVersion = (raw) => { + const match = VERSION_PATTERN.exec(String(raw).trim()) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4], + } +} + +export const LEVEL_RANK = { none: 0, patch: 1, minor: 2, major: 3 } + +/** + * The level a version move actually pays. `lower` and `unorderable` are not + * levels — they are the two ways a move pays nothing, kept distinct so a + * failure can say which happened. + */ +export const versionBumpLevel = (baseVersion, headVersion) => { + const base = parseVersion(baseVersion) + const head = parseVersion(headVersion) + if (!base || !head) return 'unorderable' + if (head.major !== base.major) return head.major > base.major ? 'major' : 'lower' + if (head.minor !== base.minor) return head.minor > base.minor ? 'minor' : 'lower' + if (head.patch !== base.patch) return head.patch > base.patch ? 'patch' : 'lower' + if (head.prerelease === base.prerelease) return 'none' + // Same release triple. Leaving a prerelease for its release, or advancing to a + // later prerelease, is the smallest move that reaches a consumer. Adding a + // prerelease to an already-released version goes backwards. + if (base.prerelease === undefined) return 'lower' + if (head.prerelease === undefined) return 'patch' + return head.prerelease > base.prerelease ? 'patch' : 'lower' +} + +/** + * The level a surface change must move, read off the consumer's compatibility + * boundary rather than off semver's headline rule. + * + * `^1.2.3` admits every 1.x, so the boundary sits at MAJOR: a break has to move + * major, and an addition has to move minor to be reachable through a `~1.2` + * style pin. `^0.2.3` — and the fleet's explicit `>=0.2.3 <0.3.0` window — + * admits only 0.2.x, so the boundary moves one position right. A break has to + * move MINOR, because that is what lands outside every existing consumer + * window, and an addition needs only PATCH, because that is already reachable + * inside one. Demanding a minor for every added export on a 0.x package would + * push every consumer range forward for a change that breaks nobody. + */ +export const requiredBumpLevel = (severity, baseVersion) => { + if (severity === 'none') return 'none' + const base = parseVersion(baseVersion) + // An unreadable base version cannot establish a boundary; demand the strongest + // level rather than guess downwards. + const stable = base === null || base.major >= 1 + if (severity === 'breaking') return stable ? 'major' : 'minor' + return stable ? 'minor' : 'patch' +} + +/** Path of a package's surface record, relative to the repository root. */ +export const surfaceRecordPath = (packageDirectory) => { + const directory = packageDirectory === '' || packageDirectory === '.' ? '' : packageDirectory + return directory === '' ? 'api-surface.json' : posix.join(directory, 'api-surface.json') +} + +export const relativeToRoot = (repoRoot, absolutePath) => + relative(repoRoot, absolutePath).split('\\').join('/') diff --git a/tests/version-bump-check.test.ts b/tests/version-bump-check.test.ts new file mode 100644 index 0000000..779f2ce --- /dev/null +++ b/tests/version-bump-check.test.ts @@ -0,0 +1,634 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +const roots: string[] = [] + +/** + * A throwaway repository with the two shapes that matter: a root manifest whose + * peer ranges a consumer resolves against, and a workspace package that reaches + * its dependency versions through a `catalog:` pin. + */ +async function createRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-runtime-version-bump-')) + roots.push(root) + await git(root, 'init', '--quiet', '--initial-branch=main') + await mkdir(join(root, 'bench')) + await writeManifests(root, { + version: '1.0.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await writeFile(join(root, 'source.ts'), 'export const value = 1\n') + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'value', AgentSpec: 'type' }, + }) + await writeSurface(root, 'bench', '@tangle-network/agent-bench', { '.': { runBench: 'value' } }) + await commit(root, 'base') + return root +} + +/** + * An export surface record for a fixture package. Every publishable manifest + * must carry one, so the fixture writes one wherever it writes a manifest. + */ +async function writeSurface( + root: string, + directory: string, + name: string, + entries: Record>, +): Promise { + await writeFile( + join(root, directory, 'api-surface.json'), + `${JSON.stringify({ package: name, entries, assets: [] }, null, 2)}\n`, + ) +} + +async function writeManifests( + root: string, + spec: { version: string; evalPeer: string; knowledgeCatalog: string; benchVersion: string }, +): Promise { + await writeFile( + join(root, 'package.json'), + `${JSON.stringify( + { + name: '@tangle-network/agent-runtime', + version: spec.version, + exports: { '.': './dist/index.js' }, + files: ['dist', 'README.md'], + dependencies: { '@tangle-network/agent-knowledge': 'catalog:' }, + peerDependencies: { '@tangle-network/agent-eval': spec.evalPeer }, + devDependencies: { vitest: '^4.1.10' }, + scripts: { build: 'tsdown' }, + }, + null, + 2, + )}\n`, + ) + await writeFile( + join(root, 'bench', 'package.json'), + `${JSON.stringify( + { + name: '@tangle-network/agent-bench', + version: spec.benchVersion, + dependencies: { '@tangle-network/agent-knowledge': 'catalog:' }, + }, + null, + 2, + )}\n`, + ) + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - bench', + '', + 'catalog:', + ` '@tangle-network/agent-knowledge': ${spec.knowledgeCatalog}`, + '', + ].join('\n'), + ) +} + +async function git(root: string, ...args: string[]): Promise { + // -c core.hooksPath keeps a developer's global hooks out of the fixture; the + // fixture identity is local so it never depends on machine git config. + const { stdout } = await execFileAsync( + 'git', + ['-C', root, '-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', ...args], + { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' } }, + ) + return stdout +} + +async function commit(root: string, message: string): Promise { + await git(root, 'add', '-A') + await git( + root, + '-c', + 'user.email=t@t.dev', + '-c', + 'user.name=T', + 'commit', + '--quiet', + '-m', + message, + ) + return (await git(root, 'rev-parse', 'HEAD')).trim() +} + +async function check(root: string, base: string) { + return execFileAsync(process.execPath, ['scripts/check-version-bump.mjs'], { + cwd: process.cwd(), + env: { + ...process.env, + PACKAGE_VERSION_BUMP_ROOT: root, + PACKAGE_VERSION_BUMP_BASE: base, + }, + }) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('consumer-visible change requires a version bump', () => { + it('rejects a peer range move that keeps the same version', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.0.0', + evalPeer: '>=0.141.0 <0.142.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move the peer range') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining( + 'peerDependencies.@tangle-network/agent-eval: ">=0.140.1 <0.141.0" -> ">=0.141.0 <0.142.0"', + ), + }) + }) + + it('accepts the same move once the version pays for it', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.1.0', + evalPeer: '>=0.141.0 <0.142.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move the peer range and bump') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('paid for by 1.0.0 -> 1.1.0'), + }) + }) + + it('rejects a catalog pin move even though every manifest is byte-identical', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.0.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.5', + benchVersion: '0.4.9', + }) + await commit(root, 'move only the catalog pin') + + // This is the shape that shipped 0.119.0 twice: the specifier string never + // changes, so a manifest diff shows nothing while the resolved version moves. + expect((await git(root, 'diff', base, 'HEAD', '--', 'package.json')).trim()).toBe('') + const failure = await check(root, base).catch((error) => error) + expect(failure.stderr).toContain( + 'dependencies.@tangle-network/agent-knowledge: "7.0.4" -> "7.0.5"', + ) + // Every publishable package that resolves through the pin, not just the root. + expect(failure.stderr).toContain('bench/package.json (@tangle-network/agent-bench)') + }) + + it('does not fire on an ordinary source-only change', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeFile(join(root, 'source.ts'), 'export const value = 2\n') + await commit(root, 'edit source') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('does not fire on a devDependency or script change', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json').then((raw) => raw)) + manifest.devDependencies.vitest = '^4.2.0' + manifest.scripts.lint = 'biome check src' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'bump a devDependency and add a script') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('fails closed when a catalog specifier resolves to nothing', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeFile( + join(root, 'pnpm-workspace.yaml'), + ['packages:', ' - bench', '', 'catalog: {}', ''].join('\n'), + ) + await commit(root, 'drop the catalog entry the dependency points at') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('has no catalog entry for it'), + }) + }) + + it('fails closed when the named base cannot be resolved', async () => { + const root = await createRepo() + + await expect(check(root, '0000000000000000000000000000000000000000')).rejects.toMatchObject({ + stderr: expect.stringContaining('cannot resolve the requested base commit'), + }) + }) + + it('fails closed on a CI event that should have had a base branch', async () => { + const root = await createRepo() + await writeFile(join(root, 'source.ts'), 'export const value = 2\n') + await commit(root, 'edit source') + + await expect( + execFileAsync(process.execPath, ['scripts/check-version-bump.mjs'], { + cwd: process.cwd(), + env: { + ...process.env, + PACKAGE_VERSION_BUMP_ROOT: root, + PACKAGE_VERSION_BUMP_BASE: '', + GITHUB_BASE_REF: '', + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'merge_group', + }, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining('with no base branch to compare against'), + }) + }) + + it('rejects a downgrade, which lands on a version the registry may already hold', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '0.9.0', + evalPeer: '>=0.141.0 <0.142.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move the peer range and lower the version') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('moves 1.0.0 -> 0.9.0, which is not higher'), + }) + }) + + it('rejects an install-lifecycle script that would run on a consumer machine', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.scripts.postinstall = 'node ./dist/postinstall.js' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'add a postinstall script') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('installScripts'), + }) + }) + + it('rejects a typesVersions change', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.typesVersions = { '*': { '*': ['dist/*'] } } + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'add typesVersions') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('typesVersions'), + }) + }) + + it('compares a moved package against the name it already published, not a new path', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await mkdir(join(root, 'packages', 'bench'), { recursive: true }) + const manifest = JSON.parse(await git(root, 'show', 'HEAD:bench/package.json')) + manifest.peerDependencies = { '@tangle-network/agent-eval': '>=0.141.0 <0.142.0' } + await writeFile( + join(root, 'packages', 'bench', 'package.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + await writeSurface(root, join('packages', 'bench'), '@tangle-network/agent-bench', { + '.': { runBench: 'value' }, + }) + await rm(join(root, 'bench'), { recursive: true }) + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + '', + 'catalog:', + " '@tangle-network/agent-knowledge': 7.0.4", + '', + ].join('\n'), + ) + await commit(root, 'move bench and change its peers') + + // Relocating a directory must not buy a free pass on an already-published name. + const failure = await check(root, base).catch((error) => error) + expect(failure.stderr).toContain('@tangle-network/agent-bench') + expect(failure.stderr).toContain( + 'peerDependencies: (absent) -> {"@tangle-network/agent-eval":">=0.141.0 <0.142.0"}', + ) + }) + + it('rejects marking a published package private without a version bump', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:bench/package.json')) + manifest.private = true + await writeFile(join(root, 'bench', 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'mark bench private') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('private'), + }) + }) + + it('does not fire when a dependency moves between a catalog and an identical literal pin', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.dependencies['@tangle-network/agent-knowledge'] = '7.0.4' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'inline the catalog pin at the same version') + + // The tarball is byte-identical; only the authoring style moved. + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged'), + }) + }) + + it('does not fire when files is reordered', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.files = ['README.md', 'dist'] + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'reorder files') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged'), + }) + }) + + it('does not police a directory the workspace excludes', async () => { + const root = await createRepo() + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - bench', + ' - fixtures/*', + ' - "!fixtures/scratch"', + '', + 'catalog:', + " '@tangle-network/agent-knowledge': 7.0.4", + '', + ].join('\n'), + ) + await mkdir(join(root, 'fixtures', 'scratch'), { recursive: true }) + await writeFile( + join(root, 'fixtures', 'scratch', 'package.json'), + `${JSON.stringify({ name: 'scratch-fixture', version: '1.0.0', files: ['a'] }, null, 2)}\n`, + ) + await commit(root, 'add an excluded fixture package') + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + + await writeFile( + join(root, 'fixtures', 'scratch', 'package.json'), + `${JSON.stringify({ name: 'scratch-fixture', version: '1.0.0', files: ['a', 'b'] }, null, 2)}\n`, + ) + await commit(root, 'edit the excluded fixture') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('reaches a package nested deeper than one level under a ** pattern', async () => { + const root = await createRepo() + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - bench', + ' - packages/**', + '', + 'catalog:', + " '@tangle-network/agent-knowledge': 7.0.4", + '', + ].join('\n'), + ) + await mkdir(join(root, 'packages', 'group', 'nested'), { recursive: true }) + await writeFile( + join(root, 'packages', 'group', 'nested', 'package.json'), + `${JSON.stringify( + { name: '@tangle-network/nested', version: '1.0.0', peerDependencies: { react: '>=18' } }, + null, + 2, + )}\n`, + ) + await writeSurface(root, join('packages', 'group', 'nested'), '@tangle-network/nested', { + '.': { nested: 'value' }, + }) + await commit(root, 'add a nested workspace package') + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + + await writeFile( + join(root, 'packages', 'group', 'nested', 'package.json'), + `${JSON.stringify( + { name: '@tangle-network/nested', version: '1.0.0', peerDependencies: { react: '>=19' } }, + null, + 2, + )}\n`, + ) + await commit(root, 'move the nested package peer range') + + const failure = await check(root, base).catch((error) => error) + expect(failure.stderr).toContain('@tangle-network/nested') + expect(failure.stderr).toContain('peerDependencies.react: ">=18" -> ">=19"') + }) + + it('survives a workspace file that does not exist', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + // The catalog is gone, so nothing may resolve through it any more. + manifest.dependencies['@tangle-network/agent-knowledge'] = '7.0.4' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await rm(join(root, 'pnpm-workspace.yaml')) + await rm(join(root, 'bench'), { recursive: true }) + await commit(root, 'drop the workspace file') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged'), + }) + }) +}) + +describe('a change to the exported symbols requires a version bump', () => { + it('rejects an added export while every manifest field stays identical', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'value', AgentSpec: 'type', PursuitProjection: 'type' }, + }) + await commit(root, 'add an export') + + // The shape that shipped PursuitProjection into no published version: the + // manifest never moves, so the manifest comparison sees nothing at all. + expect((await git(root, 'diff', base, 'HEAD', '--', 'package.json')).trim()).toBe('') + const failure = await check(root, base).catch((error) => error) + expect(failure.stderr).toContain('export added: . PursuitProjection') + expect(failure.stderr).toContain('additive change needing a minor bump') + }) + + it('accepts the added export once a minor pays for it', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.1.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'value', AgentSpec: 'type', PursuitProjection: 'type' }, + }) + await commit(root, 'add an export and bump the minor') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('1.0.0 -> 1.1.0 (minor)'), + }) + }) + + it('rejects a patch for an added export on a 1.x package', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.0.1', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'value', AgentSpec: 'type', PursuitProjection: 'type' }, + }) + await commit(root, 'add an export and bump only the patch') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('moves 1.0.0 -> 1.0.1, only a patch bump'), + }) + }) + + it('demands a major for a removed export on a 1.x package', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.1.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await writeSurface(root, '.', '@tangle-network/agent-runtime', { '.': { runAgent: 'value' } }) + await commit(root, 'remove an export and bump the minor') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('breaking change needing a major bump'), + }) + }) + + it('treats a value that becomes type-only as breaking', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'type', AgentSpec: 'type' }, + }) + await commit(root, 'drop the runtime binding behind an export') + + const failure = await check(root, base).catch((error) => error) + expect(failure.stderr).toContain('export narrowed: . runAgent: value -> type') + expect(failure.stderr).toContain('needing a major bump') + }) + + it('moves the boundary one position right below 1.0', async () => { + const root = await createRepo() + await writeManifests(root, { + version: '0.140.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move to a 0.x version') + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + + // A consumer window is `>=0.140.0 <0.141.0`, so a patch already reaches an + // addition and a break has to leave the window at the minor. + await writeManifests(root, { + version: '0.140.1', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'value', AgentSpec: 'type', PursuitProjection: 'type' }, + }) + await commit(root, 'add an export and bump the patch') + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('needing a patch bump'), + }) + + await writeSurface(root, '.', '@tangle-network/agent-runtime', { '.': { runAgent: 'value' } }) + await commit(root, 'remove an export on the same patch') + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('breaking change needing a minor bump'), + }) + }) + + it('does not fire when the exported symbols do not move', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeFile(join(root, 'source.ts'), 'export const value = 2\n') + await commit(root, 'edit source only') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('records a first surface without demanding payment for it', async () => { + const root = await createRepo() + await git(root, 'rm', '--quiet', 'api-surface.json', 'bench/api-surface.json') + await commit(root, 'a history with no surface record') + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeSurface(root, '.', '@tangle-network/agent-runtime', { + '.': { runAgent: 'value', AgentSpec: 'type' }, + }) + await writeSurface(root, 'bench', '@tangle-network/agent-bench', { '.': { runBench: 'value' } }) + await commit(root, 'record the surface for the first time') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('fails closed when a publishable package states no surface at all', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await git(root, 'rm', '--quiet', 'api-surface.json') + await commit(root, 'delete the surface record') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('api-surface.json does not exist'), + }) + }) +}) From 9ec6dbc47e266750c8821106072c4c4c23546db5 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 18 Aug 2026 14:12:29 -0600 Subject: [PATCH 2/2] refactor(release): keep check-api-surface.mjs identical to its siblings --- scripts/check-api-surface.mjs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/check-api-surface.mjs b/scripts/check-api-surface.mjs index edb5d94..f1139ce 100644 --- a/scripts/check-api-surface.mjs +++ b/scripts/check-api-surface.mjs @@ -60,13 +60,18 @@ const declarationKind = (node) => { } const declaredNames = (node) => { - if (node.type === 'VariableDeclaration') { - return node.declarations.map((declarator) => declarator.id.name) + const named = (identifier, context) => { + // A destructuring pattern carries no single name. It cannot appear in a + // declaration file, so reaching here means the parse is not what this + // reader assumes — record nothing rather than a key of `undefined`. + if (identifier?.type === 'Identifier') return identifier.name + if (identifier?.type === 'Literal') return identifier.value + throw new Error(`cannot read the exported name of a ${context} (${identifier?.type})`) } - if (node.type === 'TSModuleDeclaration') { - return [node.id.type === 'Identifier' ? node.id.name : node.id.value] + if (node.type === 'VariableDeclaration') { + return node.declarations.map((declarator) => named(declarator.id, 'variable declarator')) } - return [node.id.name] + return [named(node.id, node.type)] } /** @@ -91,10 +96,18 @@ const resolveRelativeDeclaration = (fromFile, specifier) => { * Every symbol `file` exports, name to kind. Follows `export * from` into the * chunk files the build splits declarations across, because those re-exports * are what a consumer's entry point actually resolves through. + * + * `resolved` memoizes a finished file and `visiting` breaks a cycle. Keeping the + * two apart means a chunk reached by several paths is parsed once and still + * answers with its symbols on every path, instead of answering empty on all but + * the first. The entry point's total is the same either way, because every path + * merges upward into it; the cost and the per-file answer are not. */ -const exportsOfFile = (file, seen = new Set()) => { - if (seen.has(file)) return {} - seen.add(file) +const exportsOfFile = (file, resolved = new Map(), visiting = new Set()) => { + const memoized = resolved.get(file) + if (memoized !== undefined) return memoized + if (visiting.has(file)) return {} + visiting.add(file) const source = readFileSync(file, 'utf8') const parsed = parseSync(file, source, { lang: 'dts' }) if (parsed.errors.length > 0) { @@ -134,7 +147,7 @@ const exportsOfFile = (file, seen = new Set()) => { names[name] = 'value' break } - for (const [name, kind] of Object.entries(exportsOfFile(target, seen))) { + for (const [name, kind] of Object.entries(exportsOfFile(target, resolved, visiting))) { names[name] = node.exportKind === 'type' ? 'type' : kind } break @@ -146,6 +159,8 @@ const exportsOfFile = (file, seen = new Set()) => { throw new Error(`${file} uses unhandled export form ${node.type}`) } } + visiting.delete(file) + resolved.set(file, names) return names }