@mnstry/atelier 0.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +383 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +262 -0
- package/SECURITY.md +93 -0
- package/TRADEMARKS.md +88 -0
- package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
- package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
- package/bin/atelier.mjs +2 -0
- package/bin/mnstry-atelier.mjs +3 -0
- package/contracts/analysis-adapter.v1.schema.json +248 -0
- package/contracts/atelier-action-intent.v1.schema.json +143 -0
- package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
- package/contracts/atelier-attestation.v1.schema.json +239 -0
- package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
- package/contracts/atelier-claim.v1.schema.json +97 -0
- package/contracts/atelier-export.v1.schema.json +1015 -0
- package/contracts/atelier-extension-pack.v1.schema.json +290 -0
- package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
- package/contracts/atelier-lock.v1.schema.json +364 -0
- package/contracts/atelier-migration.v1.schema.json +162 -0
- package/contracts/atelier-project-config.v1.schema.json +352 -0
- package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
- package/contracts/atelier-readiness-run.v1.schema.json +229 -0
- package/contracts/atelier-readiness.v1.schema.json +457 -0
- package/contracts/compat-baseline.json +10 -0
- package/contracts/git-promote-event.v1.schema.json +145 -0
- package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
- package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
- package/docs/announcements.md +101 -0
- package/docs/atelier-runtime.md +34 -0
- package/docs/attestation.md +201 -0
- package/docs/continuity.md +55 -0
- package/docs/contract-stability.md +111 -0
- package/docs/distributions.md +220 -0
- package/docs/install.md +140 -0
- package/docs/knowledge-graph.md +140 -0
- package/docs/ontology.md +206 -0
- package/docs/release-engineering.md +151 -0
- package/docs/repo-boundary-guard.md +199 -0
- package/docs/tenant-readiness.md +64 -0
- package/docs/upgrade.md +124 -0
- package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
- package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
- package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
- package/fixtures/analysis/valid/output.claims.v1.json +17 -0
- package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
- package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
- package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
- package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
- package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
- package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
- package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
- package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
- package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
- package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
- package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
- package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
- package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
- package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
- package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
- package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
- package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
- package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
- package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
- package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
- package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
- package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
- package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
- package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
- package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
- package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
- package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
- package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
- package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
- package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
- package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
- package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
- package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
- package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
- package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
- package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
- package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
- package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
- package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
- package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
- package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
- package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
- package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
- package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
- package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
- package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
- package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
- package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
- package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
- package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
- package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
- package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
- package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
- package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
- package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
- package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
- package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
- package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
- package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
- package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
- package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
- package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
- package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
- package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
- package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
- package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
- package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
- package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
- package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
- package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
- package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
- package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
- package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
- package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
- package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
- package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
- package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
- package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
- package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
- package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
- package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
- package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
- package/fixtures/git-promote/private-to-team.v1.json +29 -0
- package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
- package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
- package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
- package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
- package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
- package/fixtures/projects/neutral-project/atelier.project.json +15 -0
- package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
- package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
- package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
- package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
- package/fixtures/projects/sample-workspace/content/README.md +16 -0
- package/fixtures/projects/sample-workspace/content/source.html +5 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
- package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
- package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
- package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
- package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
- package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
- package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
- package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
- package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
- package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
- package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
- package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
- package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
- package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
- package/fixtures/support/invalid/leaky-support.v1.json +13 -0
- package/fixtures/support/valid/minimal-safe.v1.json +20 -0
- package/package.json +98 -0
- package/skills/claude/atelier-open/SKILL.md +68 -0
- package/skills/claude/mnstry-readiness/SKILL.md +41 -0
- package/skills/codex/atelier-open/SKILL.md +68 -0
- package/skills/codex/mnstry-readiness/SKILL.md +41 -0
- package/src/analysis/adapter.mjs +20 -0
- package/src/analysis/analysis-claim-contract.mjs +335 -0
- package/src/attestation/jcs.mjs +64 -0
- package/src/attestation/sign.mjs +305 -0
- package/src/boundary/content-rules.mjs +262 -0
- package/src/boundary/policy.mjs +806 -0
- package/src/check-atelier-export-contract.mjs +116 -0
- package/src/cli/run.mjs +283 -0
- package/src/collaboration/proposals.mjs +262 -0
- package/src/commands/analysis.mjs +8 -0
- package/src/commands/announcements.mjs +308 -0
- package/src/commands/attestation.mjs +198 -0
- package/src/commands/boundary.mjs +25 -0
- package/src/commands/config.mjs +4 -0
- package/src/commands/context.mjs +3 -0
- package/src/commands/distribution.mjs +224 -0
- package/src/commands/egress.mjs +11 -0
- package/src/commands/extension-pack.mjs +209 -0
- package/src/commands/feedback.mjs +3 -0
- package/src/commands/graph.mjs +3 -0
- package/src/commands/init.mjs +130 -0
- package/src/commands/lock.mjs +3 -0
- package/src/commands/project.mjs +3 -0
- package/src/commands/promote.mjs +4 -0
- package/src/commands/readiness.mjs +3 -0
- package/src/commands/server.mjs +6 -0
- package/src/commands/setup.mjs +250 -0
- package/src/commands/support.mjs +3 -0
- package/src/commands/upgrade.mjs +3 -0
- package/src/contracts/corpus.mjs +171 -0
- package/src/egress/check.mjs +45 -0
- package/src/egress/forbidden-egress.mjs +323 -0
- package/src/export/atelier-export-contract.mjs +722 -0
- package/src/extension-packs/loader.mjs +491 -0
- package/src/graph/graph.mjs +370 -0
- package/src/graph/knowledge-graph.mjs +887 -0
- package/src/harness/context-client.mjs +144 -0
- package/src/harness/context.mjs +27 -0
- package/src/index.mjs +88 -0
- package/src/project/config.mjs +557 -0
- package/src/project/file-class.mjs +113 -0
- package/src/project/git-ignore.mjs +31 -0
- package/src/project/path-match.mjs +30 -0
- package/src/project/repo-identity.mjs +176 -0
- package/src/projection/alignment-projection.mjs +169 -0
- package/src/projection/policy.mjs +90 -0
- package/src/projection/project.mjs +152 -0
- package/src/readiness/readiness.mjs +312 -0
- package/src/readiness-protocols/bundled-pack.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +419 -0
- package/src/server/local-sidecar.mjs +640 -0
- package/src/server/security.mjs +229 -0
- package/src/server/server.mjs +42 -0
- package/src/support/feedback-report.mjs +414 -0
- package/src/support/support-bundle.mjs +226 -0
- package/src/ui/html-primitives.mjs +104 -0
- package/src/upgrade/upgrade.mjs +701 -0
- package/src/validate-atelier-export-dry-run.mjs +75 -0
- package/templates/atelier.project.example.json +23 -0
- package/templates/distribution-workspace/.claude/launch.json +12 -0
- package/templates/distribution-workspace/README.md +86 -0
- package/templates/distribution-workspace/atelier.project.json +36 -0
- package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
- package/templates/distribution-workspace/gitignore +29 -0
- package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
- package/templates/distribution-workspace/project/README.md +19 -0
- package/templates/distribution-workspace/repo-access.v1.json +9 -0
- package/templates/launch.json +12 -0
- package/templates/private-domain-workspace/.claude/launch.json +12 -0
- package/templates/private-domain-workspace/README.md +50 -0
- package/templates/private-domain-workspace/atelier.project.json +28 -0
- package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
- package/templates/private-domain-workspace/domain/README.md +19 -0
- package/templates/private-domain-workspace/gitignore +29 -0
- package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
- package/templates/private-domain-workspace/repo-access.v1.json +9 -0
- package/templates/shared-project-workspace/.claude/launch.json +12 -0
- package/templates/shared-project-workspace/README.md +51 -0
- package/templates/shared-project-workspace/atelier.project.json +28 -0
- package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
- package/templates/shared-project-workspace/gitignore +29 -0
- package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
- package/templates/shared-project-workspace/project/README.md +19 -0
- package/templates/shared-project-workspace/repo-access.v1.json +9 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { VALID_AUDIENCES } from '../projection/policy.mjs'
|
|
4
|
+
import { generatedProjectionBasenames } from '../project/file-class.mjs'
|
|
5
|
+
import { gitIgnoreFilter } from '../project/git-ignore.mjs'
|
|
6
|
+
|
|
7
|
+
export const KNOWLEDGE_GRAPH_SCHEMA = 'mnstry.knowledge-graph@v1'
|
|
8
|
+
export const REPO_ACCESS_SCHEMA = 'mnstry.repo-access@v1'
|
|
9
|
+
export const SOURCE_SIDECAR_SCHEMA = 'mnstry.source-sidecar@v1'
|
|
10
|
+
|
|
11
|
+
export const DOC_EXTENSIONS = new Set(['.md', '.html', '.pdf', '.docx'])
|
|
12
|
+
export const VALID_RELATION_TYPES = new Set(['related', 'supports', 'supersedes', 'implements', 'depends_on', 'evidences', 'contradicts', 'belongs_to'])
|
|
13
|
+
export const VALID_STATUSES = new Set(['active', 'draft', 'archived', 'template'])
|
|
14
|
+
export const VALID_KG_TYPES = new Set(['document', 'artifact', 'evidence', 'source', 'index', 'contract', 'guide', 'runbook', 'policy', 'report', 'prototype', 'research', 'decision', 'map', 'manifest', 'html', 'pdf', 'docx'])
|
|
15
|
+
export const VALID_SIDECAR_KG_TYPES = new Set(['html', 'pdf', 'docx', 'artifact', 'evidence', 'source', 'prototype', 'research', 'report', 'manifest'])
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib'])
|
|
18
|
+
// Derived from the kit's file-class declaration, never restated here.
|
|
19
|
+
const GENERATED_FILES = generatedProjectionBasenames()
|
|
20
|
+
const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
|
|
21
|
+
const AUDIENCE_READ_RANK = {
|
|
22
|
+
public: 0,
|
|
23
|
+
team: 1,
|
|
24
|
+
operator: 2,
|
|
25
|
+
staff: 2,
|
|
26
|
+
private: 3,
|
|
27
|
+
sensitive: 3,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const yamlString = (value) => JSON.stringify(String(value ?? ''))
|
|
31
|
+
const relPath = (root, abs) => path.relative(root, abs).split(path.sep).join('/')
|
|
32
|
+
const posixJoin = (...parts) => parts.filter(Boolean).join('/').replace(/\/+/g, '/')
|
|
33
|
+
|
|
34
|
+
export const slug = (value) =>
|
|
35
|
+
String(value ?? '')
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/\.[^.]+$/, '')
|
|
38
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
39
|
+
.replace(/^-+|-+$/g, '')
|
|
40
|
+
|
|
41
|
+
export const titleCase = (value) =>
|
|
42
|
+
String(value ?? '')
|
|
43
|
+
.replace(/\.[^.]+$/, '')
|
|
44
|
+
.replace(/[-_]+/g, ' ')
|
|
45
|
+
.replace(/\s+/g, ' ')
|
|
46
|
+
.trim()
|
|
47
|
+
.replace(/\b\w/g, (m) => m.toUpperCase())
|
|
48
|
+
|
|
49
|
+
export function fallbackId(repoName, rel, ext = path.extname(rel).toLowerCase()) {
|
|
50
|
+
const suffix = ext && ext !== '.md' ? `-${ext.slice(1)}` : ''
|
|
51
|
+
return `${repoName}:${slug(rel)}${suffix}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function portableText(value) {
|
|
55
|
+
return String(value ?? '')
|
|
56
|
+
.replace(/\/Users\/[^/\s`'")]+/g, '~')
|
|
57
|
+
.replace(/\/home\/[^/\s`'")]+/g, '~')
|
|
58
|
+
.replace(/[A-Za-z]:\\Users\\[^\\\s`'")]+/g, '~')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readJsonFile(file, fallback = null) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
return fallback
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writeJsonFile(file, value) {
|
|
70
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
71
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPlainObject(value) {
|
|
75
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function asString(value, fallback = '') {
|
|
79
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function asStringArray(value) {
|
|
83
|
+
if (!Array.isArray(value)) return []
|
|
84
|
+
return value.map((item) => String(item).trim()).filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function uniqueStringArrayErrors(value, label) {
|
|
88
|
+
const errors = []
|
|
89
|
+
if (!Array.isArray(value)) return [`${label} must be a list`]
|
|
90
|
+
const seen = new Set()
|
|
91
|
+
for (const item of value) {
|
|
92
|
+
if (typeof item !== 'string' || !item.trim()) {
|
|
93
|
+
errors.push(`${label} must contain non-empty strings`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (seen.has(item)) errors.push(`${label} must not contain duplicate "${item}"`)
|
|
97
|
+
seen.add(item)
|
|
98
|
+
}
|
|
99
|
+
return errors
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function splitFrontmatter(raw) {
|
|
103
|
+
const normalized = raw.replace(/\r\n/g, '\n')
|
|
104
|
+
if (!normalized.startsWith('---\n')) return null
|
|
105
|
+
const rest = normalized.slice(4)
|
|
106
|
+
const match = rest.match(/\n---\s*\n/)
|
|
107
|
+
if (!match) return null
|
|
108
|
+
const end = 4 + match.index
|
|
109
|
+
const closeEnd = end + match[0].length
|
|
110
|
+
return {
|
|
111
|
+
yaml: normalized.slice(4, end),
|
|
112
|
+
body: normalized.slice(closeEnd),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseYamlScalar(value) {
|
|
117
|
+
const trimmed = String(value ?? '').trim()
|
|
118
|
+
if (!trimmed) return ''
|
|
119
|
+
if (trimmed === '[]') return []
|
|
120
|
+
if (trimmed === 'true') return true
|
|
121
|
+
if (trimmed === 'false') return false
|
|
122
|
+
if (trimmed === 'null') return null
|
|
123
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(trimmed)
|
|
126
|
+
} catch {
|
|
127
|
+
return trimmed.slice(1, -1)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1)
|
|
131
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
132
|
+
return trimmed
|
|
133
|
+
.slice(1, -1)
|
|
134
|
+
.split(',')
|
|
135
|
+
.map((part) => parseYamlScalar(part))
|
|
136
|
+
.filter((part) => part !== '')
|
|
137
|
+
}
|
|
138
|
+
return trimmed
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function nextYamlValue(lines, fromIndex, indent) {
|
|
142
|
+
for (let i = fromIndex + 1; i < lines.length; i += 1) {
|
|
143
|
+
const raw = lines[i]
|
|
144
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue
|
|
145
|
+
const nextIndent = raw.match(/^\s*/)[0].length
|
|
146
|
+
if (nextIndent <= indent) return {}
|
|
147
|
+
return raw.trim().startsWith('- ') ? [] : {}
|
|
148
|
+
}
|
|
149
|
+
return {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parseYamlSubset(yaml) {
|
|
153
|
+
const root = {}
|
|
154
|
+
const stack = [{ indent: -1, value: root }]
|
|
155
|
+
const lines = yaml.replace(/\t/g, ' ').replace(/\r\n/g, '\n').split('\n')
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
158
|
+
const raw = lines[i]
|
|
159
|
+
const trimmed = raw.trim()
|
|
160
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
161
|
+
const indent = raw.match(/^\s*/)[0].length
|
|
162
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
|
|
163
|
+
const parent = stack[stack.length - 1].value
|
|
164
|
+
|
|
165
|
+
if (trimmed.startsWith('- ')) {
|
|
166
|
+
if (Array.isArray(parent)) parent.push(parseYamlScalar(trimmed.slice(2)))
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const match = trimmed.match(/^([^:]+):(.*)$/)
|
|
171
|
+
if (!match) continue
|
|
172
|
+
const key = match[1].trim()
|
|
173
|
+
const valuePart = match[2].trim()
|
|
174
|
+
if (!key || Array.isArray(parent)) continue
|
|
175
|
+
if (valuePart) {
|
|
176
|
+
parent[key] = parseYamlScalar(valuePart)
|
|
177
|
+
} else {
|
|
178
|
+
parent[key] = nextYamlValue(lines, i, indent)
|
|
179
|
+
stack.push({ indent, value: parent[key] })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return root
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function markdownMetadata(raw) {
|
|
187
|
+
const fm = splitFrontmatter(raw)
|
|
188
|
+
return fm ? parseYamlSubset(fm.yaml) : {}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function relationMap(value) {
|
|
192
|
+
if (!isPlainObject(value)) return {}
|
|
193
|
+
const relations = {}
|
|
194
|
+
for (const [key, items] of Object.entries(value)) {
|
|
195
|
+
relations[key] = Array.isArray(items) ? asStringArray(items) : items
|
|
196
|
+
}
|
|
197
|
+
return relations
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractMarkdownTitle(raw, rel) {
|
|
201
|
+
const body = splitFrontmatter(raw)?.body ?? raw
|
|
202
|
+
const h1 = body.match(/^#\s+(.+?)\s*$/m)
|
|
203
|
+
return h1 ? h1[1].trim() : titleCase(path.basename(rel))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function extractMarkdownSummary(raw) {
|
|
207
|
+
const body = splitFrontmatter(raw)?.body ?? raw
|
|
208
|
+
for (const line of body.replace(/\r\n/g, '\n').split('\n')) {
|
|
209
|
+
const trimmed = line.trim()
|
|
210
|
+
if (!trimmed) continue
|
|
211
|
+
if (/^(#|---|>|```|\||[-*]\s|\d+\.)/.test(trimmed)) continue
|
|
212
|
+
return portableText(trimmed.replace(/\s+/g, ' ')).slice(0, 180)
|
|
213
|
+
}
|
|
214
|
+
return ''
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function decodeHtml(value) {
|
|
218
|
+
return String(value ?? '')
|
|
219
|
+
.replaceAll('&', '&')
|
|
220
|
+
.replaceAll('<', '<')
|
|
221
|
+
.replaceAll('>', '>')
|
|
222
|
+
.replaceAll('"', '"')
|
|
223
|
+
.replaceAll(''', "'")
|
|
224
|
+
.replaceAll(''', "'")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function extractHtmlMeta(abs, rel) {
|
|
228
|
+
const html = fs.readFileSync(abs, 'utf8')
|
|
229
|
+
const meta = {}
|
|
230
|
+
const re = /<meta\s+name="([^"]+)"\s+content="([^"]*)"\s*\/?>/gi
|
|
231
|
+
let m
|
|
232
|
+
while ((m = re.exec(html))) meta[m[1]] = decodeHtml(m[2])
|
|
233
|
+
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim()
|
|
234
|
+
return {
|
|
235
|
+
title: meta['atelier:title'] || decodeHtml(title || titleCase(path.basename(rel))),
|
|
236
|
+
summary: portableText(meta['atelier:summary'] || ''),
|
|
237
|
+
atelier: {
|
|
238
|
+
section: meta['atelier:section'] || null,
|
|
239
|
+
status: meta['atelier:status'] || null,
|
|
240
|
+
kind: meta['atelier:kind'] || null,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function inferLifecycle(rel) {
|
|
246
|
+
const top = rel.split('/')[0] || ''
|
|
247
|
+
if (top === 'INBOX') return 'inbox'
|
|
248
|
+
if (top === 'WORK IN PROGRESS') return 'work-in-progress'
|
|
249
|
+
if (top === 'REFERENCES') return 'reference'
|
|
250
|
+
if (top === 'ARCHIVE') return 'archive'
|
|
251
|
+
if (top === 'research') return 'research'
|
|
252
|
+
if (top === 'discovery') return 'discovery'
|
|
253
|
+
if (top === 'DESIGN') return 'design'
|
|
254
|
+
if (top === 'APP') return 'app'
|
|
255
|
+
if (top === 'PORTALS') return 'portal'
|
|
256
|
+
if (top === 'WORKSPACE') return 'workspace'
|
|
257
|
+
if (top === 'docs') return 'docs'
|
|
258
|
+
return 'root'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function inferDomain(_repoName, rel) {
|
|
262
|
+
const parts = rel.split('/')
|
|
263
|
+
const top = parts[0] || ''
|
|
264
|
+
if (top === 'DESIGN') return 'design'
|
|
265
|
+
if (top === 'APP') return 'app'
|
|
266
|
+
if (top === 'PORTALS' || top === 'WORKSPACE') return 'prototype'
|
|
267
|
+
if (top === 'research') return 'research'
|
|
268
|
+
if (top === 'discovery') return 'discovery'
|
|
269
|
+
if (top === 'REFERENCES') return parts[1] || 'reference'
|
|
270
|
+
if (top === 'runbooks' || top === 'policies' || top === 'launch' || top === 'staff') return 'operations'
|
|
271
|
+
return 'workstream'
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function inferStatus(lifecycle, rel) {
|
|
275
|
+
if (lifecycle === 'archive') return 'archived'
|
|
276
|
+
if (lifecycle === 'inbox' || lifecycle === 'work-in-progress') return 'draft'
|
|
277
|
+
if (/template/i.test(rel)) return 'template'
|
|
278
|
+
return 'active'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function inferTags(repoName, rel, domain, lifecycle) {
|
|
282
|
+
const tags = new Set([repoName, domain, lifecycle])
|
|
283
|
+
for (const part of rel.split('/')) {
|
|
284
|
+
const partSlug = slug(part)
|
|
285
|
+
if (!partSlug || partSlug.length < 3) continue
|
|
286
|
+
if (['readme', 'index', 'authoring'].includes(partSlug)) continue
|
|
287
|
+
if (tags.size >= 10) break
|
|
288
|
+
tags.add(partSlug)
|
|
289
|
+
}
|
|
290
|
+
return [...tags].sort()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function listRepos(workspaceRoot) {
|
|
294
|
+
return fs
|
|
295
|
+
.readdirSync(workspaceRoot, { withFileTypes: true })
|
|
296
|
+
.filter((ent) => ent.isDirectory() && !ent.name.startsWith('.'))
|
|
297
|
+
.map((ent) => path.join(workspaceRoot, ent.name))
|
|
298
|
+
.filter((dir) => fs.existsSync(path.join(dir, '.git')))
|
|
299
|
+
.sort()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Git-ignored paths are machine-local. Counting them makes committed graph
|
|
303
|
+
// artifacts differ per machine, which turns every sync tick into a conflict.
|
|
304
|
+
export function walkDocuments(dir, root, acc = [], isIgnored = gitIgnoreFilter(root)) {
|
|
305
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
306
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
|
|
307
|
+
const abs = path.join(dir, ent.name)
|
|
308
|
+
const rel = relPath(root, abs)
|
|
309
|
+
if (isIgnored(rel)) continue
|
|
310
|
+
if (ent.isDirectory()) {
|
|
311
|
+
walkDocuments(abs, root, acc, isIgnored)
|
|
312
|
+
} else if (ent.isFile()) {
|
|
313
|
+
// Sidecar-first census: document extensions are always sources; any
|
|
314
|
+
// other file opts in by carrying an adjacent .kg.json sidecar. The
|
|
315
|
+
// sidecar itself is metadata, never a source asset.
|
|
316
|
+
if (ent.name.endsWith('.kg.json')) continue
|
|
317
|
+
if (GENERATED_FILES.has(ent.name)) continue
|
|
318
|
+
if (rel === 'index.html') continue
|
|
319
|
+
const ext = path.extname(ent.name).toLowerCase()
|
|
320
|
+
// Opt-in requires a VISIBLE sidecar. A git-ignored one is machine-local:
|
|
321
|
+
// it would enrol a node that exists in no tracked file, and hand it an
|
|
322
|
+
// audience, on one machine only. Markdown never consults a sidecar.
|
|
323
|
+
const sidecarVisible = ext === '.md' ? false : sidecarIsVisible(abs, root, isIgnored)
|
|
324
|
+
if (!DOC_EXTENSIONS.has(ext) && !sidecarVisible) continue
|
|
325
|
+
acc.push({ abs, rel, ext, sidecarVisible })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return acc
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function sidecarIsVisible(abs, root, isIgnored) {
|
|
332
|
+
const sidecar = `${abs}.kg.json`
|
|
333
|
+
return fs.existsSync(sidecar) && !isIgnored(relPath(root, sidecar))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Sidecars that exist on disk but are git-ignored, next to a file the census
|
|
337
|
+
// can see: metadata the walk refused. Reported alongside the graph (like
|
|
338
|
+
// orphan sidecars) and never inside it, so committed artifacts stay a function
|
|
339
|
+
// of tracked state alone.
|
|
340
|
+
export function ignoredSourceSidecars(repoName, repoRoot, isIgnored = gitIgnoreFilter(repoRoot)) {
|
|
341
|
+
const found = []
|
|
342
|
+
function visit(dir) {
|
|
343
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
344
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
|
|
345
|
+
const abs = path.join(dir, ent.name)
|
|
346
|
+
const rel = relPath(repoRoot, abs)
|
|
347
|
+
if (ent.isDirectory()) {
|
|
348
|
+
if (!isIgnored(rel)) visit(abs)
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (!ent.isFile() || !ent.name.endsWith('.kg.json') || !isIgnored(rel)) continue
|
|
352
|
+
const sourceRel = rel.replace(/\.kg\.json$/, '')
|
|
353
|
+
if (sourceRel.toLowerCase().endsWith('.md')) continue
|
|
354
|
+
if (isIgnored(sourceRel) || !fs.existsSync(path.join(repoRoot, sourceRel))) continue
|
|
355
|
+
found.push({ repo: repoName, path: sourceRel, sidecar: rel })
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
visit(repoRoot)
|
|
359
|
+
return found
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function walkSidecars(dir, root, acc = [], isIgnored = gitIgnoreFilter(root)) {
|
|
363
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
364
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
|
|
365
|
+
const abs = path.join(dir, ent.name)
|
|
366
|
+
if (isIgnored(relPath(root, abs))) continue
|
|
367
|
+
if (ent.isDirectory()) {
|
|
368
|
+
walkSidecars(abs, root, acc, isIgnored)
|
|
369
|
+
} else if (ent.isFile() && ent.name.endsWith('.kg.json')) {
|
|
370
|
+
acc.push({ abs, rel: relPath(root, abs) })
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return acc
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function sidecarPath(file) {
|
|
377
|
+
return `${file.abs}.kg.json`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function sidecarMetadata(file) {
|
|
381
|
+
const sidecar = sidecarPath(file)
|
|
382
|
+
// The walk already decided whether this sidecar is visible in tracked state.
|
|
383
|
+
// An ignored one is treated as absent, so it can neither describe the asset
|
|
384
|
+
// nor satisfy the demand a document extension makes for a sidecar.
|
|
385
|
+
const exists = file.sidecarVisible === true
|
|
386
|
+
let parsed = null
|
|
387
|
+
let parseError = ''
|
|
388
|
+
if (exists) {
|
|
389
|
+
try {
|
|
390
|
+
parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8'))
|
|
391
|
+
} catch (err) {
|
|
392
|
+
parseError = err instanceof Error ? err.message : String(err)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
sidecar,
|
|
397
|
+
metadata: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {},
|
|
398
|
+
parseError,
|
|
399
|
+
present: exists,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function validateSourceSidecar(metadata, assetFilename) {
|
|
404
|
+
const errors = []
|
|
405
|
+
if (!isPlainObject(metadata)) return ['sidecar must be a JSON object']
|
|
406
|
+
|
|
407
|
+
const allowedRoot = new Set(['schema', 'asset', 'title', 'summary', 'tags', 'kg'])
|
|
408
|
+
for (const key of Object.keys(metadata)) {
|
|
409
|
+
if (!allowedRoot.has(key)) errors.push(`sidecar has unknown property "${key}"`)
|
|
410
|
+
}
|
|
411
|
+
for (const key of allowedRoot) {
|
|
412
|
+
if (!Object.hasOwn(metadata, key)) errors.push(`sidecar must declare ${key}`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (metadata.schema !== SOURCE_SIDECAR_SCHEMA) errors.push(`sidecar must declare schema "${SOURCE_SIDECAR_SCHEMA}"`)
|
|
416
|
+
if (metadata.asset !== assetFilename) errors.push(`sidecar asset "${metadata.asset ?? ''}" must match source filename "${assetFilename}"`)
|
|
417
|
+
if (typeof metadata.title !== 'string' || !metadata.title.trim()) errors.push('sidecar title must be a non-empty string')
|
|
418
|
+
if (typeof metadata.summary !== 'string') errors.push('sidecar summary must be a string')
|
|
419
|
+
errors.push(...uniqueStringArrayErrors(metadata.tags, 'sidecar tags'))
|
|
420
|
+
|
|
421
|
+
if (!isPlainObject(metadata.kg)) {
|
|
422
|
+
errors.push('sidecar kg must be an object')
|
|
423
|
+
return errors
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const allowedKg = new Set(['id', 'type', 'domain', 'lifecycle', 'status', 'audience', 'relations'])
|
|
427
|
+
for (const key of Object.keys(metadata.kg)) {
|
|
428
|
+
if (!allowedKg.has(key)) errors.push(`sidecar kg has unknown property "${key}"`)
|
|
429
|
+
}
|
|
430
|
+
for (const key of allowedKg) {
|
|
431
|
+
if (!Object.hasOwn(metadata.kg, key)) errors.push(`sidecar must declare kg.${key}`)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (typeof metadata.kg.id !== 'string' || !/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9:._/-]*$/.test(metadata.kg.id)) {
|
|
435
|
+
errors.push('sidecar kg.id must be a stable repo-prefixed id')
|
|
436
|
+
}
|
|
437
|
+
if (!VALID_SIDECAR_KG_TYPES.has(metadata.kg.type)) errors.push(`sidecar kg.type "${metadata.kg.type ?? ''}" is invalid`)
|
|
438
|
+
if (typeof metadata.kg.domain !== 'string' || !metadata.kg.domain.trim()) errors.push('sidecar kg.domain must be a non-empty string')
|
|
439
|
+
if (typeof metadata.kg.lifecycle !== 'string' || !metadata.kg.lifecycle.trim()) errors.push('sidecar kg.lifecycle must be a non-empty string')
|
|
440
|
+
if (!VALID_STATUSES.has(metadata.kg.status)) errors.push(`sidecar kg.status "${metadata.kg.status ?? ''}" is invalid`)
|
|
441
|
+
if (!VALID_AUDIENCES.has(metadata.kg.audience)) errors.push(`sidecar kg.audience "${metadata.kg.audience ?? ''}" is invalid`)
|
|
442
|
+
if (!isPlainObject(metadata.kg.relations)) {
|
|
443
|
+
errors.push('sidecar kg.relations must be an object')
|
|
444
|
+
} else {
|
|
445
|
+
for (const [type, targets] of Object.entries(metadata.kg.relations)) {
|
|
446
|
+
if (!VALID_RELATION_TYPES.has(type)) errors.push(`sidecar kg.relations type "${type}" is invalid`)
|
|
447
|
+
errors.push(...uniqueStringArrayErrors(targets, `sidecar kg.relations.${type}`))
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return errors
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function validateRepoAccessConfig(config, repoNames, { configPath = 'repo-access config', externalRepos = [] } = {}) {
|
|
455
|
+
const errors = []
|
|
456
|
+
const external = new Set(externalRepos)
|
|
457
|
+
if (config?.schema !== REPO_ACCESS_SCHEMA) {
|
|
458
|
+
errors.push(`${configPath}: schema must be ${REPO_ACCESS_SCHEMA}`)
|
|
459
|
+
}
|
|
460
|
+
if (!VALID_AUDIENCES.has(config?.defaultReadBoundary)) {
|
|
461
|
+
errors.push(`${configPath}: defaultReadBoundary must be a valid local audience/read-boundary value`)
|
|
462
|
+
}
|
|
463
|
+
if (!isPlainObject(config?.repos)) {
|
|
464
|
+
errors.push(`${configPath}: repos must be an object`)
|
|
465
|
+
return errors
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const [repoName, entry] of Object.entries(config.repos)) {
|
|
469
|
+
if (external.has(repoName)) {
|
|
470
|
+
errors.push(`${configPath}: repos.${repoName} is declared kind "external" and must not declare a read boundary`)
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
if (!isPlainObject(entry)) {
|
|
474
|
+
errors.push(`${configPath}: repos.${repoName} must declare readBoundary`)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
if (!VALID_AUDIENCES.has(entry.readBoundary)) {
|
|
478
|
+
errors.push(`${configPath}: repos.${repoName}.readBoundary must be a valid local audience/read-boundary value`)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const repoName of repoNames) {
|
|
483
|
+
if (external.has(repoName)) continue
|
|
484
|
+
if (!Object.hasOwn(config.repos, repoName)) {
|
|
485
|
+
errors.push(
|
|
486
|
+
`${configPath}: repos.${repoName} must declare readBoundary, or be declared with kind "external" in the project config if it is not an Atelier-managed repo`,
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return errors
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function repoReadBoundary(repoAccessConfig, repoName) {
|
|
495
|
+
const entry = repoAccessConfig?.repos?.[repoName]
|
|
496
|
+
if (typeof entry?.readBoundary === 'string' && entry.readBoundary) return entry.readBoundary
|
|
497
|
+
if (typeof repoAccessConfig?.defaultReadBoundary === 'string' && repoAccessConfig.defaultReadBoundary) return repoAccessConfig.defaultReadBoundary
|
|
498
|
+
return 'team'
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function loadAtelierCoverage(repoRoot) {
|
|
502
|
+
const manifestPath = path.join(repoRoot, 'atelier.manifest.json')
|
|
503
|
+
const surfaced = new Set()
|
|
504
|
+
const sections = new Map()
|
|
505
|
+
if (!fs.existsSync(manifestPath)) return { surfaced, sections }
|
|
506
|
+
try {
|
|
507
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
508
|
+
for (const sec of manifest.sections ?? []) {
|
|
509
|
+
for (const item of sec.items ?? []) {
|
|
510
|
+
if (item.source) {
|
|
511
|
+
surfaced.add(item.source)
|
|
512
|
+
sections.set(item.source, sec.id)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const doc of manifest.documents ?? []) {
|
|
517
|
+
if (doc.source) {
|
|
518
|
+
surfaced.add(doc.source)
|
|
519
|
+
sections.set(doc.source, doc.section || 'documents')
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} catch {
|
|
523
|
+
return { surfaced, sections }
|
|
524
|
+
}
|
|
525
|
+
return { surfaced, sections }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function generatedFrontmatter({ id, repoName, rel, title, summary, domain, lifecycle, status, audience, surfaced, tags }) {
|
|
529
|
+
return [
|
|
530
|
+
`title: ${yamlString(title)}`,
|
|
531
|
+
summary ? `summary: ${yamlString(summary)}` : `summary: ""`,
|
|
532
|
+
'kg:',
|
|
533
|
+
` id: ${yamlString(id || fallbackId(repoName, rel, '.md'))}`,
|
|
534
|
+
' type: "document"',
|
|
535
|
+
` repo: ${yamlString(repoName)}`,
|
|
536
|
+
` path: ${yamlString(rel)}`,
|
|
537
|
+
` domain: ${yamlString(domain)}`,
|
|
538
|
+
` lifecycle: ${yamlString(lifecycle)}`,
|
|
539
|
+
` status: ${yamlString(status)}`,
|
|
540
|
+
` audience: ${yamlString(audience || 'private')}`,
|
|
541
|
+
` surfaced: ${surfaced ? 'true' : 'false'}`,
|
|
542
|
+
' relations:',
|
|
543
|
+
' related: []',
|
|
544
|
+
' supports: []',
|
|
545
|
+
' supersedes: []',
|
|
546
|
+
'tags:',
|
|
547
|
+
...tags.map((tag) => ` - ${yamlString(tag)}`),
|
|
548
|
+
].join('\n')
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig) {
|
|
552
|
+
const inferredLifecycle = inferLifecycle(file.rel)
|
|
553
|
+
const inferredDomain = inferDomain(repoName, file.rel)
|
|
554
|
+
const inferredStatus = inferStatus(inferredLifecycle, file.rel)
|
|
555
|
+
const surfaced = coverage.surfaced.has(file.rel)
|
|
556
|
+
let title = titleCase(path.basename(file.rel))
|
|
557
|
+
let summary = ''
|
|
558
|
+
let metadata = {}
|
|
559
|
+
let sidecar = null
|
|
560
|
+
let hasSidecar = false
|
|
561
|
+
let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
|
|
562
|
+
|
|
563
|
+
if (file.ext === '.md') {
|
|
564
|
+
const raw = fs.readFileSync(file.abs, 'utf8')
|
|
565
|
+
metadata = markdownMetadata(raw)
|
|
566
|
+
title = asString(metadata.title, extractMarkdownTitle(raw, file.rel))
|
|
567
|
+
summary = asString(metadata.summary, extractMarkdownSummary(raw))
|
|
568
|
+
} else if (file.ext === '.html') {
|
|
569
|
+
const meta = extractHtmlMeta(file.abs, file.rel)
|
|
570
|
+
title = meta.title
|
|
571
|
+
summary = meta.summary
|
|
572
|
+
atelier = { ...atelier, ...meta.atelier }
|
|
573
|
+
const sidecarData = sidecarMetadata(file)
|
|
574
|
+
sidecar = relPath(repoRoot, sidecarData.sidecar)
|
|
575
|
+
hasSidecar = sidecarData.present
|
|
576
|
+
metadata = sidecarData.metadata
|
|
577
|
+
if (sidecarData.parseError) metadata = { ...metadata, __parseError: sidecarData.parseError }
|
|
578
|
+
if (metadata.title) title = asString(metadata.title, title)
|
|
579
|
+
if (metadata.summary) summary = portableText(asString(metadata.summary, summary))
|
|
580
|
+
} else {
|
|
581
|
+
const sidecarData = sidecarMetadata(file)
|
|
582
|
+
sidecar = relPath(repoRoot, sidecarData.sidecar)
|
|
583
|
+
hasSidecar = sidecarData.present
|
|
584
|
+
metadata = sidecarData.metadata
|
|
585
|
+
if (sidecarData.parseError) metadata = { ...metadata, __parseError: sidecarData.parseError }
|
|
586
|
+
title = asString(metadata.title, title)
|
|
587
|
+
summary = portableText(asString(metadata.summary, summary))
|
|
588
|
+
}
|
|
589
|
+
summary = portableText(summary)
|
|
590
|
+
|
|
591
|
+
const kg = isPlainObject(metadata.kg) ? metadata.kg : {}
|
|
592
|
+
const hasKgBlock = isPlainObject(metadata.kg)
|
|
593
|
+
const hasExplicitKgId = typeof kg.id === 'string' && kg.id.trim() !== ''
|
|
594
|
+
const domain = asString(kg.domain, inferredDomain)
|
|
595
|
+
const lifecycle = asString(kg.lifecycle, inferredLifecycle)
|
|
596
|
+
const status = asString(kg.status, inferredStatus)
|
|
597
|
+
const audience = asString(kg.audience, '')
|
|
598
|
+
const frontmatterHasLegacyVisibility = Object.hasOwn(kg, 'visibility')
|
|
599
|
+
const type = asString(kg.type, file.ext === '.md' ? 'document' : file.ext.slice(1))
|
|
600
|
+
const id = asString(kg.id, fallbackId(repoName, file.rel, file.ext))
|
|
601
|
+
const tags = asStringArray(metadata.tags)
|
|
602
|
+
const relations = relationMap(kg.relations)
|
|
603
|
+
const resolvedTags = tags.length ? tags : inferTags(repoName, file.rel, domain, lifecycle)
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
id,
|
|
607
|
+
title,
|
|
608
|
+
summary,
|
|
609
|
+
repo: repoName,
|
|
610
|
+
path: file.rel,
|
|
611
|
+
extension: file.ext.slice(1),
|
|
612
|
+
type: file.ext === '.md' ? 'markdown' : file.ext.slice(1),
|
|
613
|
+
kgType: type,
|
|
614
|
+
domain,
|
|
615
|
+
lifecycle,
|
|
616
|
+
status,
|
|
617
|
+
audience,
|
|
618
|
+
...(frontmatterHasLegacyVisibility ? { frontmatterHasLegacyVisibility: true } : {}),
|
|
619
|
+
...(file.ext === '.md' ? { markdownHasKgBlock: hasKgBlock, markdownHasKgId: hasExplicitKgId } : {}),
|
|
620
|
+
...(file.ext !== '.md'
|
|
621
|
+
? {
|
|
622
|
+
sidecar,
|
|
623
|
+
sidecarSchema: asString(metadata.schema, ''),
|
|
624
|
+
sidecarAsset: asString(metadata.asset, ''),
|
|
625
|
+
sidecarSchemaErrors: hasSidecar
|
|
626
|
+
? [
|
|
627
|
+
...(metadata.__parseError ? [`sidecar JSON must parse: ${metadata.__parseError}`] : []),
|
|
628
|
+
...validateSourceSidecar(
|
|
629
|
+
Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== '__parseError')),
|
|
630
|
+
path.basename(file.rel),
|
|
631
|
+
),
|
|
632
|
+
]
|
|
633
|
+
: [],
|
|
634
|
+
hasSidecar,
|
|
635
|
+
sidecarHasKgId: hasExplicitKgId,
|
|
636
|
+
}
|
|
637
|
+
: {}),
|
|
638
|
+
repoAccess: {
|
|
639
|
+
readBoundary: repoReadBoundary(repoAccessConfig, repoName),
|
|
640
|
+
},
|
|
641
|
+
surfaced,
|
|
642
|
+
atelier,
|
|
643
|
+
tags: resolvedTags,
|
|
644
|
+
relations,
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function markdownLinkEdges(repoRoot, nodesByPath) {
|
|
649
|
+
const edges = []
|
|
650
|
+
for (const [rel, node] of nodesByPath) {
|
|
651
|
+
if (!rel.endsWith('.md')) continue
|
|
652
|
+
const raw = fs.readFileSync(path.join(repoRoot, rel), 'utf8')
|
|
653
|
+
const re = /\[[^\]]+\]\(([^)\s#]+)(?:#[^)]+)?\)/g
|
|
654
|
+
let m
|
|
655
|
+
while ((m = re.exec(raw))) {
|
|
656
|
+
const href = m[1]
|
|
657
|
+
if (/^[a-z]+:/i.test(href) || href.startsWith('#')) continue
|
|
658
|
+
const target = path
|
|
659
|
+
.normalize(path.join(path.dirname(rel), decodeURIComponent(href)))
|
|
660
|
+
.split(path.sep)
|
|
661
|
+
.join('/')
|
|
662
|
+
const direct = nodesByPath.get(target)
|
|
663
|
+
const index = nodesByPath.get(posixJoin(target, 'README.md')) || nodesByPath.get(posixJoin(target, 'index.md'))
|
|
664
|
+
const targetNode = direct || index
|
|
665
|
+
if (targetNode) edges.push({ source: node.id, target: targetNode.id, type: 'links_to' })
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return edges
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function declaredRelationEdges(nodes) {
|
|
672
|
+
const edges = []
|
|
673
|
+
for (const node of nodes) {
|
|
674
|
+
for (const [type, targets] of Object.entries(node.relations ?? {})) {
|
|
675
|
+
if (!Array.isArray(targets)) continue
|
|
676
|
+
for (const target of targets) edges.push({ source: node.id, target, type, declared: true })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return edges
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function uniqueEdges(edges) {
|
|
683
|
+
const seen = new Set()
|
|
684
|
+
const result = []
|
|
685
|
+
for (const edge of edges) {
|
|
686
|
+
const key = `${edge.source}\u0000${edge.target}\u0000${edge.type}\u0000${edge.declared ? 'declared' : 'derived'}`
|
|
687
|
+
if (seen.has(key)) continue
|
|
688
|
+
seen.add(key)
|
|
689
|
+
result.push(edge)
|
|
690
|
+
}
|
|
691
|
+
return result
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function graphDiagnostics(nodes) {
|
|
695
|
+
const diagnostics = []
|
|
696
|
+
for (const node of nodes) {
|
|
697
|
+
const audienceRank = AUDIENCE_READ_RANK[node.audience]
|
|
698
|
+
const readBoundary = node.repoAccess?.readBoundary
|
|
699
|
+
const readBoundaryRank = AUDIENCE_READ_RANK[readBoundary]
|
|
700
|
+
if (audienceRank == null || readBoundaryRank == null || readBoundaryRank >= audienceRank) continue
|
|
701
|
+
const privateOrSensitive = PRIVATE_AUDIENCES.has(node.audience) && readBoundary !== 'private'
|
|
702
|
+
diagnostics.push({
|
|
703
|
+
severity: 'warning',
|
|
704
|
+
type: privateOrSensitive ? 'private-repo-recommended' : 'repo-read-boundary-wider-than-audience',
|
|
705
|
+
node: node.id,
|
|
706
|
+
repo: node.repo,
|
|
707
|
+
path: node.path,
|
|
708
|
+
audience: node.audience,
|
|
709
|
+
repoAccess: node.repoAccess,
|
|
710
|
+
message: privateOrSensitive
|
|
711
|
+
? `${node.repo}/${node.path}: audience ${node.audience} is in a ${readBoundary}-readable repo`
|
|
712
|
+
: `${node.repo}/${node.path}: audience ${node.audience} is narrower than ${readBoundary} repository read access`,
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
return diagnostics
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function activeOrphanSidecars(repoName, repoRoot, files, isIgnored = gitIgnoreFilter(repoRoot)) {
|
|
719
|
+
const indexedPaths = new Set(files.map((file) => file.rel))
|
|
720
|
+
const orphans = []
|
|
721
|
+
for (const sidecar of walkSidecars(repoRoot, repoRoot, [], isIgnored)) {
|
|
722
|
+
const sourceRel = sidecar.rel.replace(/\.kg\.json$/, '')
|
|
723
|
+
if (indexedPaths.has(sourceRel)) continue
|
|
724
|
+
if (fs.existsSync(path.join(repoRoot, sourceRel))) continue
|
|
725
|
+
const metadata = readJsonFile(sidecar.abs, {})
|
|
726
|
+
const status = asString(metadata?.kg?.status, 'active')
|
|
727
|
+
if (status === 'archived' || status === 'template') continue
|
|
728
|
+
orphans.push({ repo: repoName, path: sidecar.rel, source: sourceRel, status })
|
|
729
|
+
}
|
|
730
|
+
return orphans
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
734
|
+
const errors = []
|
|
735
|
+
const seenIds = new Map()
|
|
736
|
+
|
|
737
|
+
for (const node of nodes) {
|
|
738
|
+
if (!node.id) errors.push(`${node.repo}/${node.path}: missing kg.id`)
|
|
739
|
+
if (node.extension === 'md' && node.markdownHasKgBlock && !node.markdownHasKgId) {
|
|
740
|
+
errors.push(`${node.repo}/${node.path}: Markdown kg block must declare kg.id`)
|
|
741
|
+
}
|
|
742
|
+
if (node.extension !== 'md' && !node.hasSidecar) {
|
|
743
|
+
errors.push(`${node.repo}/${node.path}: missing non-Markdown sidecar ${node.sidecar || `${node.path}.kg.json`}`)
|
|
744
|
+
}
|
|
745
|
+
if (node.extension !== 'md' && node.hasSidecar) {
|
|
746
|
+
for (const schemaError of node.sidecarSchemaErrors ?? []) {
|
|
747
|
+
errors.push(`${node.repo}/${node.path}: sidecar ${node.sidecar} ${schemaError}`)
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (seenIds.has(node.id)) {
|
|
751
|
+
errors.push(`duplicate kg.id "${node.id}" in ${seenIds.get(node.id)} and ${node.repo}/${node.path}`)
|
|
752
|
+
} else {
|
|
753
|
+
seenIds.set(node.id, `${node.repo}/${node.path}`)
|
|
754
|
+
}
|
|
755
|
+
if (node.frontmatterHasLegacyVisibility) {
|
|
756
|
+
errors.push(`${node.repo}/${node.path}: legacy kg.visibility is reserved for runtime export; use kg.audience`)
|
|
757
|
+
}
|
|
758
|
+
if (!VALID_AUDIENCES.has(node.audience)) {
|
|
759
|
+
errors.push(`${node.repo}/${node.path}: invalid or missing kg.audience "${node.audience ?? ''}"`)
|
|
760
|
+
}
|
|
761
|
+
if (!VALID_STATUSES.has(node.status)) errors.push(`${node.repo}/${node.path}: invalid kg.status "${node.status}"`)
|
|
762
|
+
if (!VALID_KG_TYPES.has(node.kgType)) errors.push(`${node.repo}/${node.path}: invalid kg.type "${node.kgType}"`)
|
|
763
|
+
for (const [type, targets] of Object.entries(node.relations ?? {})) {
|
|
764
|
+
if (!VALID_RELATION_TYPES.has(type)) errors.push(`${node.repo}/${node.path}: invalid kg.relations type "${type}"`)
|
|
765
|
+
if (!Array.isArray(targets)) {
|
|
766
|
+
errors.push(`${node.repo}/${node.path}: kg.relations.${type} must be a list`)
|
|
767
|
+
continue
|
|
768
|
+
}
|
|
769
|
+
for (const target of targets) {
|
|
770
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
771
|
+
errors.push(`${node.repo}/${node.path}: kg.relations.${type} contains an empty target`)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
for (const edge of edges) {
|
|
778
|
+
if (!edge.declared) continue
|
|
779
|
+
if (!seenIds.has(edge.target)) {
|
|
780
|
+
const sourcePath = seenIds.get(edge.source) || edge.source
|
|
781
|
+
errors.push(`${sourcePath}: declared ${edge.type} target "${edge.target}" was not found`)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
for (const orphan of orphanSidecars) {
|
|
786
|
+
errors.push(`${orphan.repo}/${orphan.path}: active orphan sidecar has no adjacent source ${orphan.source}`)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return errors
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function buildKnowledgeGraph({
|
|
793
|
+
workspaceRoot,
|
|
794
|
+
repoAccessConfig,
|
|
795
|
+
repoRoots = null,
|
|
796
|
+
repoAccessConfigPath = 'repo-access config',
|
|
797
|
+
externalRepos = [],
|
|
798
|
+
} = {}) {
|
|
799
|
+
if (!workspaceRoot) throw new Error('workspaceRoot is required')
|
|
800
|
+
const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
801
|
+
const external = new Set(externalRepos)
|
|
802
|
+
const discovered = repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)).sort() : listRepos(resolvedWorkspaceRoot)
|
|
803
|
+
// External repos are acknowledged but never walked: no document census, no
|
|
804
|
+
// sidecar demands, no projection.
|
|
805
|
+
const roots = discovered.filter((repoRoot) => !external.has(path.basename(repoRoot)))
|
|
806
|
+
const repoNames = roots.map((repoRoot) => path.basename(repoRoot))
|
|
807
|
+
const accessConfig = repoAccessConfig ?? {
|
|
808
|
+
schema: REPO_ACCESS_SCHEMA,
|
|
809
|
+
defaultReadBoundary: 'team',
|
|
810
|
+
repos: Object.fromEntries(repoNames.map((repoName) => [repoName, { readBoundary: 'team' }])),
|
|
811
|
+
}
|
|
812
|
+
const repoAccessErrors = validateRepoAccessConfig(accessConfig, repoNames, { configPath: repoAccessConfigPath, externalRepos })
|
|
813
|
+
if (repoAccessErrors.length) {
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
errors: repoAccessErrors,
|
|
817
|
+
repoGraphs: [],
|
|
818
|
+
workspaceGraph: null,
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const workspaceNodes = []
|
|
823
|
+
const workspaceEdges = []
|
|
824
|
+
const workspaceOrphanSidecars = []
|
|
825
|
+
const workspaceIgnoredSidecars = []
|
|
826
|
+
const repoGraphs = []
|
|
827
|
+
|
|
828
|
+
for (const repoRoot of roots) {
|
|
829
|
+
const repoName = path.basename(repoRoot)
|
|
830
|
+
// One batched ignore lookup per repo, shared by every walk below.
|
|
831
|
+
const isIgnored = gitIgnoreFilter(repoRoot)
|
|
832
|
+
const files = walkDocuments(repoRoot, repoRoot, [], isIgnored)
|
|
833
|
+
workspaceOrphanSidecars.push(...activeOrphanSidecars(repoName, repoRoot, files, isIgnored))
|
|
834
|
+
workspaceIgnoredSidecars.push(...ignoredSourceSidecars(repoName, repoRoot, isIgnored))
|
|
835
|
+
const coverage = loadAtelierCoverage(repoRoot)
|
|
836
|
+
const nodes = []
|
|
837
|
+
const nodesByPath = new Map()
|
|
838
|
+
|
|
839
|
+
for (const file of files) {
|
|
840
|
+
const node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
|
|
841
|
+
nodes.push(node)
|
|
842
|
+
nodesByPath.set(file.rel, node)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const edges = uniqueEdges([...markdownLinkEdges(repoRoot, nodesByPath), ...declaredRelationEdges(nodes)])
|
|
846
|
+
const graph = {
|
|
847
|
+
schema: KNOWLEDGE_GRAPH_SCHEMA,
|
|
848
|
+
repo: repoName,
|
|
849
|
+
diagnostics: graphDiagnostics(nodes),
|
|
850
|
+
nodes: nodes.sort((a, b) => a.path.localeCompare(b.path)),
|
|
851
|
+
edges: edges.sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
|
|
852
|
+
}
|
|
853
|
+
graph.nodeCount = graph.nodes.length
|
|
854
|
+
graph.edgeCount = graph.edges.length
|
|
855
|
+
|
|
856
|
+
repoGraphs.push({ repoName, repoRoot, graph })
|
|
857
|
+
workspaceNodes.push(...graph.nodes)
|
|
858
|
+
workspaceEdges.push(...graph.edges)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars)
|
|
862
|
+
const workspaceGraph = {
|
|
863
|
+
schema: KNOWLEDGE_GRAPH_SCHEMA,
|
|
864
|
+
workspace: path.basename(resolvedWorkspaceRoot),
|
|
865
|
+
diagnostics: graphDiagnostics(workspaceNodes),
|
|
866
|
+
nodes: workspaceNodes.sort((a, b) => `${a.repo}/${a.path}`.localeCompare(`${b.repo}/${b.path}`)),
|
|
867
|
+
edges: workspaceEdges.sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
|
|
868
|
+
}
|
|
869
|
+
workspaceGraph.nodeCount = workspaceGraph.nodes.length
|
|
870
|
+
workspaceGraph.edgeCount = workspaceGraph.edges.length
|
|
871
|
+
|
|
872
|
+
return {
|
|
873
|
+
ok: validationErrors.length === 0,
|
|
874
|
+
errors: validationErrors,
|
|
875
|
+
repoGraphs,
|
|
876
|
+
workspaceGraph,
|
|
877
|
+
orphanSidecars: workspaceOrphanSidecars,
|
|
878
|
+
ignoredSidecars: workspaceIgnoredSidecars,
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export function writeKnowledgeGraphs({ repoGraphs = [], workspaceGraph, workspaceGraphPath }) {
|
|
883
|
+
for (const { repoRoot, graph } of repoGraphs) {
|
|
884
|
+
writeJsonFile(path.join(repoRoot, 'knowledge.graph.json'), graph)
|
|
885
|
+
}
|
|
886
|
+
if (workspaceGraphPath && workspaceGraph) writeJsonFile(workspaceGraphPath, workspaceGraph)
|
|
887
|
+
}
|