@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +283 -0
- package/README.md +102 -52
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +197 -11
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +9 -9
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +49 -21
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +431 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +96 -8
- package/src/projection/project.mjs +21 -16
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +42 -11
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
5
|
+
import { identityKey } from '../project/repo-identity.mjs'
|
|
6
|
+
import { openRegularFileNoFollow } from '../project/private-state.mjs'
|
|
7
|
+
import { resolveGitExecutable, runGit } from '../runtime/git-adapter.mjs'
|
|
8
|
+
import { VALID_AUDIENCES } from '../projection/policy.mjs'
|
|
9
|
+
import {
|
|
10
|
+
censusScopeFilter, markdownMetadata, nodeForFile, resolveWorkspaceLinks,
|
|
11
|
+
scanMarkdownSource, validateKnowledgeGraph, walkDocuments, walkSidecars, VALID_KG_TYPES, VALID_STATUSES, VALID_RELATION_TYPES,
|
|
12
|
+
} from './knowledge-graph.mjs'
|
|
13
|
+
|
|
14
|
+
export const REPO_CONTRIBUTION_SCHEMA = 'atelier.repo-contribution@v1'
|
|
15
|
+
const PROFILE = 'markdown-explicit-id/v1'
|
|
16
|
+
const digestPattern = /^sha256:[0-9a-f]{64}$/
|
|
17
|
+
const objectIdPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
18
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
19
|
+
const plain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v))
|
|
20
|
+
const text = (v) => typeof v === 'string' && v.trim().length > 0 && !/[\u0000-\u001f]/.test(v)
|
|
21
|
+
const relative = (v) => text(v) && !v.includes('\\') && !/^(?:\/|~|[A-Za-z]:)/.test(v) && v.split('/').every((s) => s !== '' && s !== '.' && s !== '..')
|
|
22
|
+
const sourcePath = (v) => relative(v) && !v.split('/').some((part) => part.toLowerCase() === '.git')
|
|
23
|
+
const hash = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
|
|
24
|
+
const bodyDigest = (value) => hash(canonicalize(Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'digest'))))
|
|
25
|
+
const pointerToken = (value) => value.replaceAll('~', '~0').replaceAll('/', '~1')
|
|
26
|
+
|
|
27
|
+
// Locate the same small YAML subset the source parser accepts, in the raw
|
|
28
|
+
// source. These offsets never come from normalized or reserialized metadata.
|
|
29
|
+
function relationSourceLocations(raw) {
|
|
30
|
+
const found = new Map()
|
|
31
|
+
const stack = [{ indent: -1, parts: [], index: 0 }]
|
|
32
|
+
const span = (start, end) => ({ start, end, byteStart: Buffer.byteLength(raw.slice(0, start)), byteEnd: Buffer.byteLength(raw.slice(0, end)) })
|
|
33
|
+
const put = (parts, start, end) => {
|
|
34
|
+
if (parts[0] === 'kg' && parts[1] === 'relations' && parts.length >= 3) found.set(`/${parts.map(pointerToken).join('/')}`, span(start, end))
|
|
35
|
+
}
|
|
36
|
+
let first = true
|
|
37
|
+
for (const match of raw.matchAll(/[^\r\n]+|\r\n|\r|\n/g)) {
|
|
38
|
+
const line = match[0]
|
|
39
|
+
if (/^[\r\n]/.test(line)) continue
|
|
40
|
+
if (first) { first = false; continue }
|
|
41
|
+
const trimmed = line.trim()
|
|
42
|
+
if (trimmed === '---') break
|
|
43
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
44
|
+
const indent = line.replaceAll('\t', ' ').match(/^\s*/)[0].length
|
|
45
|
+
while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop()
|
|
46
|
+
const parent = stack.at(-1)
|
|
47
|
+
const position = match.index + line.indexOf(trimmed)
|
|
48
|
+
if (trimmed.startsWith('- ')) {
|
|
49
|
+
const spelling = trimmed.slice(2).trim()
|
|
50
|
+
if (spelling) put([...parent.parts, String(parent.index++)], position + trimmed.indexOf(spelling, 2), position + trimmed.length)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const keyValue = /^([^:]+):(.*)$/.exec(trimmed)
|
|
54
|
+
if (!keyValue) continue
|
|
55
|
+
const parts = [...parent.parts, keyValue[1].trim()]
|
|
56
|
+
const pointer = `/${parts.map(pointerToken).join('/')}`
|
|
57
|
+
// Repeated assignments replace their previous value in parseYamlSubset.
|
|
58
|
+
for (const previous of found.keys()) if (previous === pointer || previous.startsWith(`${pointer}/`)) found.delete(previous)
|
|
59
|
+
const value = keyValue[2].trim()
|
|
60
|
+
if (!value) { stack.push({ indent, parts, index: 0 }); continue }
|
|
61
|
+
const valueStart = position + trimmed.indexOf(':') + 1 + keyValue[2].indexOf(value)
|
|
62
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
63
|
+
let offset = valueStart + 1
|
|
64
|
+
let index = 0
|
|
65
|
+
for (const part of value.slice(1, -1).split(',')) {
|
|
66
|
+
const spelling = part.trim()
|
|
67
|
+
if (spelling && spelling !== '""' && spelling !== "''") put([...parts, String(index++)], offset + part.indexOf(spelling), offset + part.indexOf(spelling) + spelling.length)
|
|
68
|
+
offset += part.length + 1
|
|
69
|
+
}
|
|
70
|
+
} else put(parts, valueStart, valueStart + value.length)
|
|
71
|
+
}
|
|
72
|
+
return found
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class RepositoryContributionError extends Error {
|
|
76
|
+
constructor(code) {
|
|
77
|
+
super(code)
|
|
78
|
+
this.name = 'RepositoryContributionError'
|
|
79
|
+
this.code = code
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const refuse = (code) => { throw new RepositoryContributionError(code) }
|
|
83
|
+
|
|
84
|
+
export function nodeRefKey(ref) {
|
|
85
|
+
if (!plain(ref) || !text(ref.repoId) || !text(ref.localId)) refuse('invalid-node-ref')
|
|
86
|
+
return JSON.stringify([ref.repoId, ref.localId])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeScope(scope = {}) {
|
|
90
|
+
if (!plain(scope) || Object.keys(scope).some((key) => !['base', 'include', 'exclude'].includes(key))) refuse('invalid-census-scope')
|
|
91
|
+
const result = { base: scope.base ?? '', include: scope.include ?? null, exclude: scope.exclude ?? null }
|
|
92
|
+
if (typeof result.base !== 'string' || result.base.includes('\\') || /[\u0000-\u001f]/.test(result.base) || /^(?:\/|~|[A-Za-z]:)/.test(result.base)) refuse('invalid-census-scope')
|
|
93
|
+
for (const key of ['include', 'exclude']) if (result[key] !== null && (!text(result[key]) || result[key].includes('\\') || /^(?:\/|~|[A-Za-z]:)/.test(result[key]))) refuse('invalid-census-scope')
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readBytes(root, rel) {
|
|
98
|
+
if (!sourcePath(rel)) refuse('invalid-source-path')
|
|
99
|
+
let cursor = root
|
|
100
|
+
const segments = rel.split('/')
|
|
101
|
+
// The repository's own metadata identity is retained for every component, so
|
|
102
|
+
// a hard link to a worktree metadata file refuses even below a subdirectory.
|
|
103
|
+
// An observed repository always has it; its absence means the repository
|
|
104
|
+
// changed after observation, and nothing is read without the guard.
|
|
105
|
+
let rootMetadata
|
|
106
|
+
try { rootMetadata = fs.statSync(path.join(root, '.git'), { bigint: true }) } catch (error) {
|
|
107
|
+
if (error?.code !== 'ENOENT') throw error
|
|
108
|
+
refuse('repository-snapshot-changed')
|
|
109
|
+
}
|
|
110
|
+
for (const [index, segment] of segments.entries()) {
|
|
111
|
+
const next = path.join(cursor, segment)
|
|
112
|
+
const stat = fs.lstatSync(next, { bigint: true })
|
|
113
|
+
// On Windows a short-name spelling can name the same metadata object
|
|
114
|
+
// without containing a literal .git segment. Bind every component,
|
|
115
|
+
// including a worktree's metadata file, before opening source bytes.
|
|
116
|
+
let metadata
|
|
117
|
+
try { metadata = fs.statSync(path.join(cursor, '.git'), { bigint: true }) } catch (error) {
|
|
118
|
+
if (error?.code !== 'ENOENT') throw error
|
|
119
|
+
}
|
|
120
|
+
if (metadata && stat.dev === metadata.dev && stat.ino === metadata.ino) refuse('invalid-source-path')
|
|
121
|
+
if (stat.dev === rootMetadata.dev && stat.ino === rootMetadata.ino) refuse('invalid-source-path')
|
|
122
|
+
cursor = next
|
|
123
|
+
if (index < segments.length - 1) {
|
|
124
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) refuse('redirected-source-path')
|
|
125
|
+
rejectNestedRepository(cursor)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const fd = openRegularFileNoFollow(cursor)
|
|
129
|
+
try { return fs.readFileSync(fd) } finally { fs.closeSync(fd) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rejectNestedRepository(directory) {
|
|
133
|
+
try { fs.lstatSync(path.join(directory, '.git')) } catch (error) {
|
|
134
|
+
if (error?.code === 'ENOENT') return
|
|
135
|
+
throw error
|
|
136
|
+
}
|
|
137
|
+
refuse('nested-repository-source')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function gitReader(root) {
|
|
141
|
+
const executable = resolveGitExecutable()
|
|
142
|
+
return (args) => {
|
|
143
|
+
const result = runGit(executable, root, args, { allowFailure: true })
|
|
144
|
+
if (!result.ok) refuse('repository-observation-unavailable')
|
|
145
|
+
return result.stdout
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function observe(git) {
|
|
150
|
+
const head = git(['rev-parse', '--verify', 'HEAD']).trim()
|
|
151
|
+
const tree = git(['rev-parse', '--verify', 'HEAD^{tree}']).trim()
|
|
152
|
+
if (!objectIdPattern.test(head) || !objectIdPattern.test(tree)) refuse('repository-revision-unavailable')
|
|
153
|
+
const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all'])
|
|
154
|
+
if (status !== '' && !status.endsWith('\0')) refuse('repository-observation-incomplete')
|
|
155
|
+
return { head, tree, status, dirty: status.length > 0 }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function census(root, git, scope) {
|
|
159
|
+
const output = git(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'])
|
|
160
|
+
if (output !== '' && !output.endsWith('\0')) refuse('repository-observation-incomplete')
|
|
161
|
+
const ignored = new Set(output.split('\0').filter(Boolean).map((item) => item.replace(/\/+$/, '')))
|
|
162
|
+
const outside = censusScopeFilter(scope)
|
|
163
|
+
const isIgnored = (rel, info) => {
|
|
164
|
+
if (ignored.has(rel.replace(/\/+$/, '')) || (outside?.(rel, info) ?? false)) return true
|
|
165
|
+
// A nested clone or submodule owns its own source. The caller must select
|
|
166
|
+
// it separately or explicitly exclude it from this repository's scope.
|
|
167
|
+
if (info?.directory) rejectNestedRepository(path.join(root, rel))
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
const files = walkDocuments(root, root, [], isIgnored).sort((a, b) => compare(a.rel, b.rel))
|
|
171
|
+
// Other formats require the existing metadata reader's pinned-byte seam.
|
|
172
|
+
// Never silently omit them, or turn an incomplete contribution into a complete one.
|
|
173
|
+
if (files.some((file) => file.ext !== '.md') || walkSidecars(root, root, [], isIgnored).length) refuse('source-profile-unsupported')
|
|
174
|
+
return files
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A derived local contribution. No network lookup, authorization, assertion
|
|
179
|
+
* acceptance or writes occur here. Provider IDs must already be recorded by
|
|
180
|
+
* the repository's existing adoption path. Unsupported identity/format refuses.
|
|
181
|
+
* @param {{name:string, path:string, identity:{provider:'github', id:string}, aliases?:string[]}} repo
|
|
182
|
+
* @param {import('./repo-contribution.types.mjs').SourceBuilderOptions} [options]
|
|
183
|
+
* @returns {import('./repo-contribution.types.mjs').RepositoryContribution}
|
|
184
|
+
*/
|
|
185
|
+
export function buildRepositoryContribution(repo, { scope: scopeInput, inputPaths = {} } = {}) {
|
|
186
|
+
try {
|
|
187
|
+
if (!plain(repo) || !text(repo.name) || !text(repo.path)) refuse('invalid-repository')
|
|
188
|
+
if (!plain(repo.identity) || repo.identity.provider !== 'github' || !text(repo.identity.id)) refuse('stable-repository-identity-required')
|
|
189
|
+
if (!plain(inputPaths) || Object.keys(inputPaths).some((key) => !['config', 'plan'].includes(key))) refuse('invalid-input-paths')
|
|
190
|
+
const repoId = identityKey(repo.identity)
|
|
191
|
+
const scope = normalizeScope(scopeInput)
|
|
192
|
+
const root = fs.realpathSync(repo.path)
|
|
193
|
+
const git = gitReader(root)
|
|
194
|
+
// Filesystem identity handles Windows case/short-name aliases without
|
|
195
|
+
// treating a different directory as the enrolled repository root.
|
|
196
|
+
const requestedRoot = fs.statSync(root, { bigint: true })
|
|
197
|
+
const gitRoot = fs.statSync(git(['rev-parse', '--show-toplevel']).trim(), { bigint: true })
|
|
198
|
+
if (!requestedRoot.isDirectory() || !gitRoot.isDirectory() || requestedRoot.ino === 0n || gitRoot.ino === 0n || requestedRoot.dev !== gitRoot.dev || requestedRoot.ino !== gitRoot.ino) refuse('repository-root-mismatch')
|
|
199
|
+
const before = observe(git)
|
|
200
|
+
const files = census(root, git, scope)
|
|
201
|
+
const pins = new Map()
|
|
202
|
+
const contents = new Map()
|
|
203
|
+
const capture = (rel) => {
|
|
204
|
+
if (!contents.has(rel)) {
|
|
205
|
+
const bytes = readBytes(root, rel)
|
|
206
|
+
contents.set(rel, bytes)
|
|
207
|
+
pins.set(rel, { path: rel, rawDigest: hash(bytes), byteLength: bytes.length })
|
|
208
|
+
}
|
|
209
|
+
return contents.get(rel)
|
|
210
|
+
}
|
|
211
|
+
const nodes = []
|
|
212
|
+
const scanned = new Map()
|
|
213
|
+
const metadata = new Map()
|
|
214
|
+
const locations = new Map()
|
|
215
|
+
const coverage = { surfaced: new Set(), sections: new Map() }
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
const bytes = capture(file.rel)
|
|
218
|
+
const raw = bytes.toString('utf8')
|
|
219
|
+
const scan = scanMarkdownSource(bytes)
|
|
220
|
+
if (!scan.utf8Exact) refuse('source-not-utf8')
|
|
221
|
+
const node = nodeForFile(repo.name, root, coverage, file, null, raw)
|
|
222
|
+
if (node.classification !== 'classified' || node.markdownHasKgId !== true) refuse('explicit-source-identity-required')
|
|
223
|
+
nodes.push(node)
|
|
224
|
+
scanned.set(node, scan)
|
|
225
|
+
metadata.set(node, markdownMetadata(raw))
|
|
226
|
+
locations.set(node, relationSourceLocations(raw))
|
|
227
|
+
}
|
|
228
|
+
// Endpoint availability is a composition concern. Source validation still
|
|
229
|
+
// checks IDs, classifications, predicate vocabulary and relation shapes.
|
|
230
|
+
if (validateKnowledgeGraph(nodes, []).length) refuse('source-graph-invalid')
|
|
231
|
+
for (const input of Object.values(inputPaths)) capture(input)
|
|
232
|
+
// This profile resolves document links only. Keep original embed flags in
|
|
233
|
+
// the contribution, while disabling the resolver's asset-directory scan.
|
|
234
|
+
const linkScans = new Map([...scanned].map(([node, scan]) => [node, { ...scan, occurrences: scan.occurrences.map((occurrence) => ({ ...occurrence, embed: false })) }]))
|
|
235
|
+
const resolved = resolveWorkspaceLinks({ repos: [{ name: repo.name, root, nodesByPath: new Map(nodes.map((node) => [node.path, node])) }], scanned: linkScans, isAssetEligible: () => false })
|
|
236
|
+
const localTargets = new Map(resolved.links.map((link) => [JSON.stringify([link.sourcePath, link.range.start]), link.target]))
|
|
237
|
+
// Recheck inventory and every selected byte, including explicit config/plan
|
|
238
|
+
// inputs. The graph and its source pins were derived from the same buffers.
|
|
239
|
+
if (canonicalize(census(root, git, scope).map((f) => f.rel)) !== canonicalize(files.map((f) => f.rel))) refuse('source-snapshot-changed')
|
|
240
|
+
for (const [rel, pin] of pins) if (hash(readBytes(root, rel)) !== pin.rawDigest) refuse('source-snapshot-changed')
|
|
241
|
+
if (canonicalize(observe(git)) !== canonicalize(before)) refuse('repository-snapshot-changed')
|
|
242
|
+
const snapshot = { repoId, head: before.head, tree: before.tree, dirty: before.dirty, scope, files: [...pins.values()].sort((a, b) => compare(a.path, b.path)) }
|
|
243
|
+
snapshot.digest = bodyDigest(snapshot)
|
|
244
|
+
const ref = (localId) => ({ repoId, localId })
|
|
245
|
+
const source = (rel, locator) => ({ repoId, path: rel, revision: snapshot.digest, digest: pins.get(rel).rawDigest, ...(locator ? { locator: { ...locator, ...(locator.range ? { rangeDigest: hash(contents.get(rel).subarray(locator.range.byteStart, locator.range.byteEnd)) } : {}) } } : {}) })
|
|
246
|
+
const fields = ['title', 'summary', 'path', 'extension', 'type', 'kgType', 'domain', 'lifecycle', 'status', 'audience', 'classification', 'tags']
|
|
247
|
+
const contributionNodes = nodes.map((node) => ({ ref: ref(node.id), source: source(node.path), ...Object.fromEntries(fields.map((key) => [key, node[key]])) }))
|
|
248
|
+
const ordinaryLinks = nodes.flatMap((node) => scanned.get(node).occurrences.map((occurrence) => {
|
|
249
|
+
const target = localTargets.get(JSON.stringify([node.path, occurrence.range.start]))
|
|
250
|
+
return { source: ref(node.id), target: target ? ref(target) : null, origin: 'ordinary-link', syntax: occurrence.syntax, embed: occurrence.embed, href: occurrence.href, fragment: occurrence.fragment, provenance: source(node.path, { range: occurrence.range, targetRange: occurrence.targetRange }), resolution: target ? 'resolved-local' : 'unresolved' }
|
|
251
|
+
}))
|
|
252
|
+
const declaredRelations = nodes.flatMap((node) => Object.entries(node.relations).flatMap(([predicate, targets]) => targets.map((target, index) => {
|
|
253
|
+
const written = metadata.get(node).kg.relations[predicate]
|
|
254
|
+
const originalIndex = Array.isArray(written) ? written.map((item, i) => ({ value: String(item).trim(), index: i })).filter((item) => item.value)[index].index : null
|
|
255
|
+
const pointer = `/kg/relations/${pointerToken(predicate)}${originalIndex !== null ? `/${originalIndex}` : ''}`
|
|
256
|
+
const range = locations.get(node).get(pointer)
|
|
257
|
+
if (!range) refuse('relation-source-location-unavailable')
|
|
258
|
+
return { source: ref(node.id), target, predicate, origin: 'declared', provenance: source(node.path, { pointer, range }) }
|
|
259
|
+
})))
|
|
260
|
+
const contribution = {
|
|
261
|
+
schema: REPO_CONTRIBUTION_SCHEMA, sourceProfile: PROFILE,
|
|
262
|
+
repository: { repoId, identity: { provider: repo.identity.provider, id: repo.identity.id, source: 'recorded-identity' }, aliases: [...new Set([repo.name, ...(repo.aliases ?? [])])].sort(compare) },
|
|
263
|
+
snapshot, inputs: { config: inputPaths.config ? source(inputPaths.config) : null, plan: inputPaths.plan ? source(inputPaths.plan) : null },
|
|
264
|
+
nodes: contributionNodes, ordinaryLinks, declaredRelations,
|
|
265
|
+
}
|
|
266
|
+
contribution.digest = bodyDigest(contribution)
|
|
267
|
+
const validation = validateRepositoryContribution(contribution)
|
|
268
|
+
if (!validation.ok) refuse('contribution-invalid')
|
|
269
|
+
return contribution
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error instanceof RepositoryContributionError) throw error
|
|
272
|
+
refuse('source-unavailable')
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Structural and digest integrity only; never source ownership or freshness. */
|
|
277
|
+
export function validateRepositoryContribution(value) {
|
|
278
|
+
const errors = []
|
|
279
|
+
const check = (ok, code) => { if (!ok) errors.push(code); return ok }
|
|
280
|
+
const keys = (v, required, optional = []) => plain(v) && required.every((key) => Object.hasOwn(v, key)) && Object.keys(v).every((key) => [...required, ...optional].includes(key))
|
|
281
|
+
try {
|
|
282
|
+
if (!check(keys(value, ['schema', 'sourceProfile', 'repository', 'snapshot', 'inputs', 'nodes', 'ordinaryLinks', 'declaredRelations', 'digest']), 'invalid-contribution-shape')) return { ok: false, errors }
|
|
283
|
+
check(value.schema === REPO_CONTRIBUTION_SCHEMA && value.sourceProfile === PROFILE, 'unsupported-contribution-schema')
|
|
284
|
+
const repository = value.repository
|
|
285
|
+
if (!check(keys(repository, ['repoId', 'identity', 'aliases']), 'invalid-repository-shape')) return { ok: false, errors }
|
|
286
|
+
check(keys(repository.identity, ['provider', 'id', 'source']) && repository.identity.provider === 'github' && text(repository.identity.id) && repository.identity.source === 'recorded-identity' && repository.repoId === identityKey(repository.identity), 'invalid-repository-identity')
|
|
287
|
+
check(Array.isArray(repository.aliases) && repository.aliases.every(text) && new Set(repository.aliases).size === repository.aliases.length, 'invalid-repository-aliases')
|
|
288
|
+
const snapshot = value.snapshot
|
|
289
|
+
if (!check(keys(snapshot, ['repoId', 'head', 'tree', 'dirty', 'scope', 'files', 'digest']), 'invalid-snapshot-shape')) return { ok: false, errors }
|
|
290
|
+
check(snapshot.repoId === repository.repoId && objectIdPattern.test(snapshot.head) && objectIdPattern.test(snapshot.tree) && typeof snapshot.dirty === 'boolean', 'invalid-snapshot-revision')
|
|
291
|
+
check(canonicalize(normalizeScope(snapshot.scope)) === canonicalize(snapshot.scope), 'invalid-snapshot-scope')
|
|
292
|
+
check(digestPattern.test(snapshot.digest) && snapshot.digest === bodyDigest(snapshot), 'snapshot-digest-mismatch')
|
|
293
|
+
if (!check(Array.isArray(snapshot.files), 'invalid-source-files')) return { ok: false, errors }
|
|
294
|
+
const files = new Map()
|
|
295
|
+
for (const file of snapshot.files) {
|
|
296
|
+
if (check(keys(file, ['path', 'rawDigest', 'byteLength']) && sourcePath(file.path) && digestPattern.test(file.rawDigest) && Number.isSafeInteger(file.byteLength) && file.byteLength >= 0 && !files.has(file.path), 'invalid-source-file')) files.set(file.path, file)
|
|
297
|
+
}
|
|
298
|
+
const validRef = (ref) => keys(ref, ['repoId', 'localId']) && ref.repoId === repository.repoId && text(ref.localId)
|
|
299
|
+
// Exact UTF-8 needs one to three bytes per UTF-16 code unit (a surrogate
|
|
300
|
+
// pair needs four bytes for two units). These are necessary structural
|
|
301
|
+
// bounds; only the receiving host's pinned bytes can prove exact offsets.
|
|
302
|
+
const distance = (units, bytes) => units >= 0 && bytes >= units && bytes <= units * 3
|
|
303
|
+
const range = (v, pin) => keys(v, ['start', 'end', 'byteStart', 'byteEnd']) && [v.start, v.end, v.byteStart, v.byteEnd].every(Number.isSafeInteger) && v.start >= 0 && v.start < v.end && v.byteStart >= 0 && v.byteStart < v.byteEnd && v.byteEnd <= pin.byteLength && distance(v.start, v.byteStart) && distance(v.end, v.byteEnd) && distance(v.end - v.start, v.byteEnd - v.byteStart)
|
|
304
|
+
const validSource = (s) => {
|
|
305
|
+
if (!keys(s, ['repoId', 'path', 'revision', 'digest'], ['locator']) || s.repoId !== repository.repoId || s.revision !== snapshot.digest || !files.has(s.path) || s.digest !== files.get(s.path).rawDigest) return false
|
|
306
|
+
if (!Object.hasOwn(s, 'locator')) return true
|
|
307
|
+
const loc = s.locator
|
|
308
|
+
return keys(loc, [], ['pointer', 'range', 'targetRange', 'rangeDigest']) && Object.keys(loc).length > 0 && (!Object.hasOwn(loc, 'pointer') || (text(loc.pointer) && loc.pointer.startsWith('/'))) && (!Object.hasOwn(loc, 'range') || (range(loc.range, files.get(s.path)) && digestPattern.test(loc.rangeDigest))) && (!Object.hasOwn(loc, 'targetRange') || (range(loc.targetRange, files.get(s.path)) && loc.range && loc.targetRange.start >= loc.range.start && loc.targetRange.end <= loc.range.end && loc.targetRange.byteStart >= loc.range.byteStart && loc.targetRange.byteEnd <= loc.range.byteEnd && distance(loc.targetRange.start - loc.range.start, loc.targetRange.byteStart - loc.range.byteStart) && distance(loc.range.end - loc.targetRange.end, loc.range.byteEnd - loc.targetRange.byteEnd))) && (!Object.hasOwn(loc, 'rangeDigest') || Object.hasOwn(loc, 'range'))
|
|
309
|
+
}
|
|
310
|
+
if (!check(Array.isArray(value.nodes) && Array.isArray(value.ordinaryLinks) && Array.isArray(value.declaredRelations), 'invalid-source-records')) return { ok: false, errors }
|
|
311
|
+
const nodeKeys = new Map()
|
|
312
|
+
const fields = ['ref', 'source', 'title', 'summary', 'path', 'extension', 'type', 'kgType', 'domain', 'lifecycle', 'status', 'audience', 'classification', 'tags']
|
|
313
|
+
for (const node of value.nodes) {
|
|
314
|
+
const valid = keys(node, fields) && validRef(node.ref) && validSource(node.source) && node.source.path === node.path && sourcePath(node.path) && fields.slice(2, -1).every((key) => typeof node[key] === 'string') && node.extension === 'md' && node.type === 'markdown' && VALID_KG_TYPES.has(node.kgType) && VALID_STATUSES.has(node.status) && VALID_AUDIENCES.has(node.audience) && node.classification === 'classified' && Array.isArray(node.tags) && node.tags.every(text)
|
|
315
|
+
if (check(valid, 'invalid-source-node')) {
|
|
316
|
+
check(!nodeKeys.has(nodeRefKey(node.ref)), 'duplicate-local-node-id')
|
|
317
|
+
nodeKeys.set(nodeRefKey(node.ref), node)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const sourceNode = (ref, source) => validRef(ref) && validSource(source) && nodeKeys.get(nodeRefKey(ref))?.path === source.path
|
|
321
|
+
for (const link of value.ordinaryLinks) check(keys(link, ['source', 'target', 'origin', 'syntax', 'embed', 'href', 'fragment', 'provenance', 'resolution']) && sourceNode(link.source, link.provenance) && link.origin === 'ordinary-link' && ['markdown', 'wikilink'].includes(link.syntax) && typeof link.embed === 'boolean' && text(link.href) && typeof link.fragment === 'string' && link.provenance.locator?.range && link.provenance.locator?.targetRange && (link.target === null ? link.resolution === 'unresolved' : validRef(link.target) && nodeKeys.has(nodeRefKey(link.target)) && link.resolution === 'resolved-local'), 'invalid-ordinary-link')
|
|
322
|
+
for (const relation of value.declaredRelations) check(keys(relation, ['source', 'target', 'predicate', 'origin', 'provenance']) && sourceNode(relation.source, relation.provenance) && text(relation.target) && VALID_RELATION_TYPES.has(relation.predicate) && relation.origin === 'declared' && relation.provenance.locator?.range && typeof relation.provenance.locator?.pointer === 'string' && new RegExp(`^/kg/relations/${pointerToken(relation.predicate)}(?:/[0-9]+)?$`).test(relation.provenance.locator.pointer), 'invalid-declared-relation')
|
|
323
|
+
check(keys(value.inputs, ['config', 'plan']) && [value.inputs.config, value.inputs.plan].every((s) => s === null || validSource(s)), 'invalid-input-pins')
|
|
324
|
+
check(digestPattern.test(value.digest) && value.digest === bodyDigest(value), 'contribution-digest-mismatch')
|
|
325
|
+
} catch { errors.push('invalid-contribution-data') }
|
|
326
|
+
return { ok: errors.length === 0, errors: [...new Set(errors)] }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Only the supplied set participates. No repository/name/nearest-match guess. */
|
|
330
|
+
export function resolveLegacyNodeRef(localId, contributions) {
|
|
331
|
+
if (!text(localId) || !Array.isArray(contributions)) refuse('invalid-legacy-node-ref')
|
|
332
|
+
const matches = []
|
|
333
|
+
const repos = new Set()
|
|
334
|
+
for (const contribution of contributions) {
|
|
335
|
+
if (!validateRepositoryContribution(contribution).ok) refuse('contribution-invalid')
|
|
336
|
+
if (repos.has(contribution.repository.repoId)) refuse('duplicate-repository-contribution')
|
|
337
|
+
repos.add(contribution.repository.repoId)
|
|
338
|
+
matches.push(...contribution.nodes.filter((node) => node.ref.localId === localId).map((node) => node.ref))
|
|
339
|
+
}
|
|
340
|
+
if (matches.length > 1) refuse('legacy-node-id-ambiguous-use-qualified-ref')
|
|
341
|
+
if (matches.length === 0) refuse('legacy-node-id-unresolved')
|
|
342
|
+
return structuredClone(matches[0])
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Pure topology over exactly the supplied contributions and explicit layout.
|
|
347
|
+
* The caller owns access decisions. Missing/ambiguous endpoints stay null;
|
|
348
|
+
* no diagnostics, files, provider calls or inferred enrollment are consulted.
|
|
349
|
+
* @param {import('./repo-contribution.types.mjs').RepositoryContribution[]} contributions
|
|
350
|
+
* @param {{enrollment:{repoId:string, root:string}[]}} options
|
|
351
|
+
* @returns {import('./repo-contribution.types.mjs').ContributionTopology}
|
|
352
|
+
*/
|
|
353
|
+
export function resolveContributionTopology(contributions, { enrollment } = {}) {
|
|
354
|
+
if (!Array.isArray(contributions) || !Array.isArray(enrollment)) refuse('invalid-contribution-enrollment')
|
|
355
|
+
const supplied = new Map()
|
|
356
|
+
for (const contribution of contributions) {
|
|
357
|
+
if (!validateRepositoryContribution(contribution).ok) refuse('contribution-invalid')
|
|
358
|
+
if (supplied.has(contribution.repository.repoId)) refuse('duplicate-repository-contribution')
|
|
359
|
+
supplied.set(contribution.repository.repoId, contribution)
|
|
360
|
+
}
|
|
361
|
+
const roots = new Map()
|
|
362
|
+
for (const entry of enrollment) {
|
|
363
|
+
if (!plain(entry) || Object.keys(entry).length !== 2 || !text(entry.repoId) || !supplied.has(entry.repoId) || roots.has(entry.repoId) || !(entry.root === '.' || relative(entry.root))) refuse('invalid-contribution-enrollment')
|
|
364
|
+
roots.set(entry.repoId, entry.root)
|
|
365
|
+
}
|
|
366
|
+
if (roots.size !== supplied.size) refuse('invalid-contribution-enrollment')
|
|
367
|
+
const virtualRoot = path.resolve(path.sep, '__atelier_contribution_enrollment__')
|
|
368
|
+
const byRef = new Map()
|
|
369
|
+
const byLocation = new Map()
|
|
370
|
+
const scanned = new Map()
|
|
371
|
+
const sorted = [...supplied.values()].sort((a, b) => compare(a.repository.repoId, b.repository.repoId))
|
|
372
|
+
const repos = sorted.map((contribution) => {
|
|
373
|
+
const repoId = contribution.repository.repoId
|
|
374
|
+
const root = path.resolve(virtualRoot, roots.get(repoId))
|
|
375
|
+
const nodesByPath = new Map()
|
|
376
|
+
for (const node of contribution.nodes) {
|
|
377
|
+
const key = nodeRefKey(node.ref)
|
|
378
|
+
const internal = { id: key, path: node.path, title: node.title }
|
|
379
|
+
nodesByPath.set(node.path, internal)
|
|
380
|
+
byRef.set(key, node.ref)
|
|
381
|
+
const location = path.resolve(root, node.path)
|
|
382
|
+
if (!byLocation.has(location)) byLocation.set(location, [])
|
|
383
|
+
byLocation.get(location).push(key)
|
|
384
|
+
// Source bytes were already scanned and pinned. Disabling only the
|
|
385
|
+
// resolver's asset fallback makes this invocation entirely free of I/O;
|
|
386
|
+
// the returned occurrence retains its original embed flag unchanged.
|
|
387
|
+
scanned.set(internal, { utf8Exact: true, occurrences: contribution.ordinaryLinks.filter((link) => nodeRefKey(link.source) === key).map((link) => ({ syntax: link.syntax, embed: false, href: link.href, fragment: link.fragment, range: link.provenance.locator.range, targetRange: link.provenance.locator.targetRange })) })
|
|
388
|
+
}
|
|
389
|
+
return { name: roots.get(repoId), root, nodesByPath }
|
|
390
|
+
})
|
|
391
|
+
const resolved = resolveWorkspaceLinks({ repos, scanned, isAssetEligible: () => false })
|
|
392
|
+
const targets = new Map()
|
|
393
|
+
for (const link of resolved.links) {
|
|
394
|
+
const candidates = byLocation.get(path.resolve(virtualRoot, link.targetRepo, link.targetPath))
|
|
395
|
+
if (candidates?.length === 1) targets.set(JSON.stringify([link.source, link.range.start]), byRef.get(link.target))
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
nodes: structuredClone(sorted.flatMap((contribution) => contribution.nodes)),
|
|
399
|
+
ordinaryLinks: sorted.flatMap((contribution) => contribution.ordinaryLinks.map((link) => {
|
|
400
|
+
const target = targets.get(JSON.stringify([nodeRefKey(link.source), link.provenance.locator.range.start])) ?? null
|
|
401
|
+
return { ...structuredClone(link), target: target ? structuredClone(target) : null, resolution: target ? target.repoId === link.source.repoId ? 'resolved-local' : 'resolved-cross-repository' : 'unresolved' }
|
|
402
|
+
})),
|
|
403
|
+
declaredRelations: structuredClone(sorted.flatMap((contribution) => contribution.declaredRelations)),
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Derived source DTOs. Digests establish integrity, never source ownership. */
|
|
2
|
+
|
|
3
|
+
/** @typedef {`sha256:${string}`} Digest */
|
|
4
|
+
/** @typedef {{repoId:string, localId:string}} NodeRef */
|
|
5
|
+
/** @typedef {{start:number, end:number, byteStart:number, byteEnd:number}} SourceRange */
|
|
6
|
+
/** @typedef {{repoId:string, path:string, revision:Digest, digest:Digest, locator?:{pointer?:string, range?:SourceRange, targetRange?:SourceRange, rangeDigest?:Digest}}} SourceRef */
|
|
7
|
+
/** @typedef {{base:string, include:string|null, exclude:string|null}} CensusScope */
|
|
8
|
+
/** @typedef {{path:string, rawDigest:Digest, byteLength:number}} SourceFilePin */
|
|
9
|
+
/** @typedef {{repoId:string, head:string, tree:string, dirty:boolean, scope:CensusScope, files:SourceFilePin[], digest:Digest}} RepositorySnapshot */
|
|
10
|
+
/** @typedef {{ref:NodeRef, source:SourceRef, title:string, summary:string, path:string, extension:'md', type:'markdown', kgType:string, domain:string, lifecycle:string, status:string, audience:string, classification:'classified', tags:string[]}} ContributionNode */
|
|
11
|
+
/** @typedef {{source:NodeRef, target:NodeRef|null, origin:'ordinary-link', syntax:'markdown'|'wikilink', embed:boolean, href:string, fragment:string, provenance:SourceRef, resolution:'resolved-local'|'unresolved'}} OrdinaryLinkOccurrence */
|
|
12
|
+
/** @typedef {{source:NodeRef, target:string, predicate:string, origin:'declared', provenance:SourceRef}} DeclaredRelation */
|
|
13
|
+
/**
|
|
14
|
+
* A source projection, not a permission decision or accepted semantic assertion.
|
|
15
|
+
* revision is snapshot.digest, including the selected dirty working-tree bytes.
|
|
16
|
+
* rangeDigest pins exactly the raw byte slice described by locator.range.
|
|
17
|
+
* @typedef {object} RepositoryContribution
|
|
18
|
+
* @property {'atelier.repo-contribution@v1'} schema
|
|
19
|
+
* @property {'markdown-explicit-id/v1'} sourceProfile
|
|
20
|
+
* @property {{repoId:string, identity:{provider:'github', id:string, source:'recorded-identity'}, aliases:string[]}} repository
|
|
21
|
+
* @property {RepositorySnapshot} snapshot
|
|
22
|
+
* @property {{config:SourceRef|null, plan:SourceRef|null}} inputs
|
|
23
|
+
* @property {ContributionNode[]} nodes
|
|
24
|
+
* @property {OrdinaryLinkOccurrence[]} ordinaryLinks
|
|
25
|
+
* @property {DeclaredRelation[]} declaredRelations
|
|
26
|
+
* @property {Digest} digest
|
|
27
|
+
*/
|
|
28
|
+
/** @typedef {{scope?:Partial<CensusScope>, inputPaths?:{config?:string, plan?:string}}} SourceBuilderOptions */
|
|
29
|
+
/** @typedef {Omit<OrdinaryLinkOccurrence, 'resolution'> & {resolution:'resolved-local'|'resolved-cross-repository'|'unresolved'}} ResolvedOrdinaryLinkOccurrence */
|
|
30
|
+
/** @typedef {{nodes:ContributionNode[], ordinaryLinks:ResolvedOrdinaryLinkOccurrence[], declaredRelations:DeclaredRelation[]}} ContributionTopology */
|
|
31
|
+
|
|
32
|
+
export {}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
2
|
+
import { immutable } from '../edge-policy/contract.mjs'
|
|
3
|
+
import { displayEligibleEdge } from '../edge-policy/policy.mjs'
|
|
4
|
+
|
|
5
|
+
const refused = Object.freeze({ eligible: false })
|
|
6
|
+
const same = (a, b) => canonicalize(a) === canonicalize(b)
|
|
7
|
+
const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value)
|
|
8
|
+
const boundedList = (value, max, itemMax) => Array.isArray(value) && value.length <= max && value.every(item => text(item, itemMax))
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert an original in-process policy decision into the graph-access edge port
|
|
12
|
+
* result. The host still owns recipient access, snapshot binding and freshness.
|
|
13
|
+
* A display projection or serialized decision cannot stand in for its original.
|
|
14
|
+
*/
|
|
15
|
+
export function adaptEdgeDecision(candidate, decision) {
|
|
16
|
+
try {
|
|
17
|
+
// This call is the policy owner's WeakSet provenance and active-state check.
|
|
18
|
+
// Do not clone the decision or trust a candidate's eligible/accepted fields.
|
|
19
|
+
const display = displayEligibleEdge(decision)
|
|
20
|
+
if (!display || !candidate || candidate.edgeId !== display.edgeId ||
|
|
21
|
+
!same(candidate.source, display.source) || !same(candidate.target, display.target) ||
|
|
22
|
+
!same(candidate.supportRefs, display.supportRefs)) return refused
|
|
23
|
+
|
|
24
|
+
// Keep complete qualifications and ordered evidence occurrences. Refuse a
|
|
25
|
+
// policy result outside the receiving port's bounds rather than clipping it.
|
|
26
|
+
if (!text(display.predicate, 128) || !boundedList(display.supportRefs, 64, 512) ||
|
|
27
|
+
display.supportRefs.length === 0 || new Set(display.supportRefs).size !== display.supportRefs.length ||
|
|
28
|
+
!display.supportRefs.includes(display.sourceRef) || !boundedList(display.qualifications, 64, 2048) ||
|
|
29
|
+
!boundedList(display.reviewRefs, 64, 2048)) return refused
|
|
30
|
+
|
|
31
|
+
return immutable({ edgeId: display.edgeId, source: display.source, target: display.target,
|
|
32
|
+
predicate: display.family === 'hyperlink' && display.predicate === 'links-to' ? 'links_to' : display.predicate,
|
|
33
|
+
family: display.family, acceptanceState: display.acceptanceState, supportRefs: [...display.supportRefs],
|
|
34
|
+
sourceRef: display.sourceRef, assurance: display.assurance, qualifications: [...display.qualifications],
|
|
35
|
+
reviewRefs: [...display.reviewRefs], recordDigest: display.recordDigest, policyDigest: display.policyDigest,
|
|
36
|
+
eligible: true })
|
|
37
|
+
} catch {
|
|
38
|
+
// Refusals disclose no candidate, audit history, source paths or port errors.
|
|
39
|
+
return refused
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Bind a supplied policy record to source pins before a host digests its candidate.
|
|
2
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
3
|
+
import { edgeIdentity, immutable } from '../edge-policy/contract.mjs'
|
|
4
|
+
import { edgeEvidenceOccurrences } from '../edge-policy/policy.mjs'
|
|
5
|
+
import { sourceCitationFromProvenance } from '../edge-policy/source-citation-adapter.mjs'
|
|
6
|
+
import { resolveLegacyNodeRef } from '../graph/repo-contribution.mjs'
|
|
7
|
+
import { adaptRepositoryContribution } from './source-adapter.mjs'
|
|
8
|
+
|
|
9
|
+
const invalid = 'Invalid graph access edge record input'
|
|
10
|
+
const external = 'Cross-repository edge record binding is unsupported'
|
|
11
|
+
const oversized = 'Edge record exceeds graph access bounds'
|
|
12
|
+
const need = (value, message = invalid) => { if (!value) throw new TypeError(message) }
|
|
13
|
+
const same = (a, b) => canonicalize(a) === canonicalize(b)
|
|
14
|
+
const key = ref => canonicalize([ref.repoId, ref.localId])
|
|
15
|
+
|
|
16
|
+
function bindCitation(contribution, citation, kind) {
|
|
17
|
+
const matches = [...contribution.ordinaryLinks, ...contribution.declaredRelations].filter(observed => {
|
|
18
|
+
const pin = observed.provenance
|
|
19
|
+
const range = pin.locator?.range
|
|
20
|
+
return (!kind || observed.origin === kind) && pin.repoId === citation.repoId && pin.path === citation.path &&
|
|
21
|
+
pin.revision === citation.revision && pin.digest === citation.fileDigest &&
|
|
22
|
+
range?.byteStart === citation.locator.byteStart && range?.byteEnd === citation.locator.byteEnd &&
|
|
23
|
+
same(sourceCitationFromProvenance(contribution, pin), citation)
|
|
24
|
+
})
|
|
25
|
+
need(matches.length === 1)
|
|
26
|
+
return matches[0]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return one complete, digestable graph-access candidate containing exactly the
|
|
31
|
+
* supplied policy record. No record, approval, grant or verification is created.
|
|
32
|
+
* ownerRef must come from the trusted host. Home pin presence is structural;
|
|
33
|
+
* current placement, custody, bytes, origin and acceptance remain host checks.
|
|
34
|
+
* This bounded adapter accepts one contribution only. All endpoints and source
|
|
35
|
+
* evidence must bind to it; cross-repository assertions await an explicit
|
|
36
|
+
* multi-contribution binding and evidence-distribution contract.
|
|
37
|
+
*/
|
|
38
|
+
export function adaptEdgeRecord(record, contribution, options = {}) {
|
|
39
|
+
try {
|
|
40
|
+
need(options && typeof options === 'object' && Object.keys(options).every(name => name === 'ownerRef'))
|
|
41
|
+
const candidate = adaptRepositoryContribution(contribution, options)
|
|
42
|
+
// Derive the owner's exact identities before the host hashes any candidate.
|
|
43
|
+
const { edgeId } = edgeIdentity(record)
|
|
44
|
+
const occurrences = edgeEvidenceOccurrences(record)
|
|
45
|
+
const repoId = contribution.repository.repoId
|
|
46
|
+
need([record.home, record.subject, record.object, ...occurrences.map(value => value.source)]
|
|
47
|
+
.every(value => value.repoId === repoId), external)
|
|
48
|
+
need(occurrences.length <= 64 && record.predicate.length <= 128, oversized)
|
|
49
|
+
const nodes = new Set(contribution.nodes.map(node => key(node.ref)))
|
|
50
|
+
need(nodes.has(key(record.subject)) && nodes.has(key(record.object)))
|
|
51
|
+
const home = contribution.snapshot.files.find(file => file.path === record.home.path)
|
|
52
|
+
need(home)
|
|
53
|
+
const originKind = record.family === 'hyperlink' ? 'ordinary-link' : record.family === 'source-declaration' ? 'declared' : undefined
|
|
54
|
+
const origin = bindCitation(contribution, record.origin, originKind)
|
|
55
|
+
if (record.family === 'hyperlink') need(record.predicate === 'links-to' &&
|
|
56
|
+
same(origin.source, record.subject) && origin.target !== null && same(origin.target, record.object))
|
|
57
|
+
if (record.family === 'source-declaration') need(same(origin.source, record.subject) && origin.predicate === record.predicate &&
|
|
58
|
+
same(resolveLegacyNodeRef(origin.target, [contribution]), record.object))
|
|
59
|
+
const evidence = occurrences.map((occurrence, index) => ({
|
|
60
|
+
evidenceId: occurrence.id,
|
|
61
|
+
sourceRef: (index === 0 ? origin : bindCitation(contribution, occurrence.source)).provenance,
|
|
62
|
+
}))
|
|
63
|
+
const sourceRef = { repoId, path: home.path, revision: contribution.snapshot.digest, digest: home.rawDigest }
|
|
64
|
+
return immutable(structuredClone({ ...candidate,
|
|
65
|
+
edges: [{ edgeId, source: record.subject, target: record.object, predicate: record.predicate,
|
|
66
|
+
family: record.family, sourceRef, supportRefs: occurrences.map(value => value.id) }],
|
|
67
|
+
evidence,
|
|
68
|
+
}))
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof TypeError && [invalid, external, oversized].includes(error.message)) throw error
|
|
71
|
+
throw new TypeError(invalid)
|
|
72
|
+
}
|
|
73
|
+
}
|