@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.5
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 +62 -0
- package/README.md +38 -12
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +39 -0
- package/docs/atelier-runtime.md +15 -0
- package/docs/blocks/claims.md +15 -9
- package/docs/design.md +12 -6
- package/docs/install.md +26 -4
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-services.md +101 -0
- package/docs/release-engineering.md +75 -10
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/upgrade.md +25 -2
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/package.json +12 -5
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/src/boundary/content-rules.mjs +278 -20
- package/src/boundary/policy.mjs +150 -60
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/run.mjs +17 -7
- package/src/collaboration/event-ledger.mjs +365 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +2 -1
- package/src/commands/init.mjs +2 -1
- package/src/commands/server.mjs +1 -4
- package/src/disclosure/content-scan.mjs +193 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/graph/graph.mjs +112 -314
- package/src/graph/knowledge-graph.mjs +94 -18
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +12 -0
- package/src/project/config.mjs +66 -7
- package/src/project/file-class.mjs +14 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +110 -0
- package/src/server/local-sidecar.mjs +81 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +3 -2
- package/src/support/feedback-report.mjs +4 -3
- package/src/upgrade/upgrade.mjs +2 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { VALID_AUDIENCES } from '../projection/policy.mjs'
|
|
4
|
-
import { generatedProjectionBasenames } from '../project/file-class.mjs'
|
|
4
|
+
import { generatedProjectionBasenames, generatedProjectionDirectoryBasenames } from '../project/file-class.mjs'
|
|
5
5
|
import { gitIgnoreFilter } from '../project/git-ignore.mjs'
|
|
6
6
|
|
|
7
7
|
export const KNOWLEDGE_GRAPH_SCHEMA = 'mnstry.knowledge-graph@v1'
|
|
@@ -14,7 +14,7 @@ export const VALID_STATUSES = new Set(['active', 'draft', 'archived', 'template'
|
|
|
14
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
15
|
export const VALID_SIDECAR_KG_TYPES = new Set(['html', 'pdf', 'docx', 'artifact', 'evidence', 'source', 'prototype', 'research', 'report', 'manifest'])
|
|
16
16
|
|
|
17
|
-
const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib'])
|
|
17
|
+
const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib', ...generatedProjectionDirectoryBasenames()])
|
|
18
18
|
// Derived from the kit's file-class declaration, never restated here.
|
|
19
19
|
const GENERATED_FILES = generatedProjectionBasenames()
|
|
20
20
|
const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
|
|
@@ -100,7 +100,7 @@ function uniqueStringArrayErrors(value, label) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function splitFrontmatter(raw) {
|
|
103
|
-
const normalized = raw.replace(/\r\n
|
|
103
|
+
const normalized = String(raw).replace(/\r\n?/g, '\n')
|
|
104
104
|
if (!normalized.startsWith('---\n')) return null
|
|
105
105
|
const rest = normalized.slice(4)
|
|
106
106
|
const match = rest.match(/\n---\s*\n/)
|
|
@@ -188,11 +188,50 @@ export function markdownMetadata(raw) {
|
|
|
188
188
|
return fm ? parseYamlSubset(fm.yaml) : {}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
function markdownFrontmatterState(raw) {
|
|
192
|
+
const fm = splitFrontmatter(raw)
|
|
193
|
+
if (!fm) return { kind: 'absent', metadata: {} }
|
|
194
|
+
if (!fm.yaml.trim()) return { kind: 'empty', metadata: {} }
|
|
195
|
+
|
|
196
|
+
const metadata = parseYamlSubset(fm.yaml)
|
|
197
|
+
const lines = fm.yaml.replace(/\t/g, ' ').replace(/\r\n/g, '\n').split('\n')
|
|
198
|
+
let malformed = false
|
|
199
|
+
const containerIndents = [-1]
|
|
200
|
+
const containerKinds = ['object']
|
|
201
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
202
|
+
const rawLine = lines[index]
|
|
203
|
+
const trimmed = rawLine.trim()
|
|
204
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
205
|
+
const indent = rawLine.match(/^\s*/)[0].length
|
|
206
|
+
while (containerIndents.length > 1 && indent <= containerIndents.at(-1)) {
|
|
207
|
+
containerIndents.pop()
|
|
208
|
+
containerKinds.pop()
|
|
209
|
+
}
|
|
210
|
+
if (trimmed.startsWith('- ')) {
|
|
211
|
+
if (containerKinds.at(-1) !== 'array' || !trimmed.slice(2).trim()) malformed = true
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const match = trimmed.match(/^([^:]+):(.*)$/)
|
|
215
|
+
if (!match || !match[1].trim()) {
|
|
216
|
+
malformed = true
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (!match[2].trim()) {
|
|
220
|
+
const next = nextYamlValue(lines, index, indent)
|
|
221
|
+
containerIndents.push(indent)
|
|
222
|
+
containerKinds.push(Array.isArray(next) ? 'array' : 'object')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { kind: malformed ? 'malformed' : 'valid', metadata }
|
|
226
|
+
}
|
|
227
|
+
|
|
191
228
|
function relationMap(value) {
|
|
192
229
|
if (!isPlainObject(value)) return {}
|
|
193
230
|
const relations = {}
|
|
194
231
|
for (const [key, items] of Object.entries(value)) {
|
|
195
|
-
|
|
232
|
+
// Preserve the original graph command's shorthand while emitting one
|
|
233
|
+
// canonical shape: a scalar Markdown relation is a one-target list.
|
|
234
|
+
relations[key] = Array.isArray(items) ? asStringArray(items) : typeof items === 'string' ? asStringArray([items]) : items
|
|
196
235
|
}
|
|
197
236
|
return relations
|
|
198
237
|
}
|
|
@@ -315,7 +354,6 @@ export function walkDocuments(dir, root, acc = [], isIgnored = gitIgnoreFilter(r
|
|
|
315
354
|
// sidecar itself is metadata, never a source asset.
|
|
316
355
|
if (ent.name.endsWith('.kg.json')) continue
|
|
317
356
|
if (GENERATED_FILES.has(ent.name)) continue
|
|
318
|
-
if (rel === 'index.html') continue
|
|
319
357
|
const ext = path.extname(ent.name).toLowerCase()
|
|
320
358
|
// Opt-in requires a VISIBLE sidecar. A git-ignored one is machine-local:
|
|
321
359
|
// it would enrol a node that exists in no tracked file, and hand it an
|
|
@@ -556,13 +594,21 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
556
594
|
let title = titleCase(path.basename(file.rel))
|
|
557
595
|
let summary = ''
|
|
558
596
|
let metadata = {}
|
|
597
|
+
let classification = 'classified'
|
|
598
|
+
let classificationReason = null
|
|
559
599
|
let sidecar = null
|
|
560
600
|
let hasSidecar = false
|
|
561
601
|
let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
|
|
562
602
|
|
|
563
603
|
if (file.ext === '.md') {
|
|
564
604
|
const raw = fs.readFileSync(file.abs, 'utf8')
|
|
565
|
-
|
|
605
|
+
const frontmatter = markdownFrontmatterState(raw)
|
|
606
|
+
metadata = frontmatter.metadata
|
|
607
|
+
if (frontmatter.kind !== 'valid' || !isPlainObject(metadata.kg)) {
|
|
608
|
+
classification = 'unclassified'
|
|
609
|
+
classificationReason = frontmatter.kind === 'valid' ? 'missing-kg-block' : `${frontmatter.kind}-frontmatter`
|
|
610
|
+
metadata = {}
|
|
611
|
+
}
|
|
566
612
|
title = asString(metadata.title, extractMarkdownTitle(raw, file.rel))
|
|
567
613
|
summary = asString(metadata.summary, extractMarkdownSummary(raw))
|
|
568
614
|
} else if (file.ext === '.html') {
|
|
@@ -594,7 +640,7 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
594
640
|
const domain = asString(kg.domain, inferredDomain)
|
|
595
641
|
const lifecycle = asString(kg.lifecycle, inferredLifecycle)
|
|
596
642
|
const status = asString(kg.status, inferredStatus)
|
|
597
|
-
const audience = asString(kg.audience, '')
|
|
643
|
+
const audience = classification === 'unclassified' ? 'private' : asString(kg.audience, '')
|
|
598
644
|
const frontmatterHasLegacyVisibility = Object.hasOwn(kg, 'visibility')
|
|
599
645
|
const type = asString(kg.type, file.ext === '.md' ? 'document' : file.ext.slice(1))
|
|
600
646
|
const id = asString(kg.id, fallbackId(repoName, file.rel, file.ext))
|
|
@@ -615,6 +661,8 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
615
661
|
lifecycle,
|
|
616
662
|
status,
|
|
617
663
|
audience,
|
|
664
|
+
classification,
|
|
665
|
+
...(classificationReason ? { classificationReason } : {}),
|
|
618
666
|
...(frontmatterHasLegacyVisibility ? { frontmatterHasLegacyVisibility: true } : {}),
|
|
619
667
|
...(file.ext === '.md' ? { markdownHasKgBlock: hasKgBlock, markdownHasKgId: hasExplicitKgId } : {}),
|
|
620
668
|
...(file.ext !== '.md'
|
|
@@ -694,6 +742,20 @@ export function uniqueEdges(edges) {
|
|
|
694
742
|
export function graphDiagnostics(nodes) {
|
|
695
743
|
const diagnostics = []
|
|
696
744
|
for (const node of nodes) {
|
|
745
|
+
if (node.classification === 'unclassified') {
|
|
746
|
+
diagnostics.push({
|
|
747
|
+
severity: 'warning',
|
|
748
|
+
type: 'unclassified-content',
|
|
749
|
+
code: 'unclassified-content',
|
|
750
|
+
node: node.id,
|
|
751
|
+
repo: node.repo,
|
|
752
|
+
path: node.path,
|
|
753
|
+
audience: node.audience,
|
|
754
|
+
repoAccess: node.repoAccess,
|
|
755
|
+
reason: node.classificationReason,
|
|
756
|
+
message: `${node.repo}/${node.path}: Markdown classification metadata is ${node.classificationReason}; enrolled as private unclassified content`,
|
|
757
|
+
})
|
|
758
|
+
}
|
|
697
759
|
const audienceRank = AUDIENCE_READ_RANK[node.audience]
|
|
698
760
|
const readBoundary = node.repoAccess?.readBoundary
|
|
699
761
|
const readBoundaryRank = AUDIENCE_READ_RANK[readBoundary]
|
|
@@ -730,14 +792,16 @@ export function activeOrphanSidecars(repoName, repoRoot, files, isIgnored = gitI
|
|
|
730
792
|
return orphans
|
|
731
793
|
}
|
|
732
794
|
|
|
733
|
-
export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
795
|
+
export function validateKnowledgeGraph(nodes, edges, orphanSidecars = [], { externalRelationPrefixes = [], externalRelationIds = [] } = {}) {
|
|
734
796
|
const errors = []
|
|
735
797
|
const seenIds = new Map()
|
|
798
|
+
const allowedExternalPrefixes = new Set(externalRelationPrefixes)
|
|
799
|
+
const allowedExternalIds = new Set(externalRelationIds)
|
|
736
800
|
|
|
737
801
|
for (const node of nodes) {
|
|
738
802
|
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}:
|
|
803
|
+
if (node.extension === 'md' && node.classification !== 'unclassified' && node.markdownHasKgBlock && !node.markdownHasKgId) {
|
|
804
|
+
errors.push(`${node.repo}/${node.path}: kg.id is required in a Markdown kg block`)
|
|
741
805
|
}
|
|
742
806
|
if (node.extension !== 'md' && !node.hasSidecar) {
|
|
743
807
|
errors.push(`${node.repo}/${node.path}: missing non-Markdown sidecar ${node.sidecar || `${node.path}.kg.json`}`)
|
|
@@ -753,7 +817,7 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
|
753
817
|
seenIds.set(node.id, `${node.repo}/${node.path}`)
|
|
754
818
|
}
|
|
755
819
|
if (node.frontmatterHasLegacyVisibility) {
|
|
756
|
-
errors.push(`${node.repo}/${node.path}:
|
|
820
|
+
errors.push(`${node.repo}/${node.path}: kg.visibility is invalid for graph classification; use kg.audience`)
|
|
757
821
|
}
|
|
758
822
|
if (!VALID_AUDIENCES.has(node.audience)) {
|
|
759
823
|
errors.push(`${node.repo}/${node.path}: invalid or missing kg.audience "${node.audience ?? ''}"`)
|
|
@@ -776,7 +840,8 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
|
776
840
|
|
|
777
841
|
for (const edge of edges) {
|
|
778
842
|
if (!edge.declared) continue
|
|
779
|
-
|
|
843
|
+
const externalPrefix = String(edge.target).split(':')[0]
|
|
844
|
+
if (!seenIds.has(edge.target) && !allowedExternalIds.has(edge.target) && !allowedExternalPrefixes.has(externalPrefix)) {
|
|
780
845
|
const sourcePath = seenIds.get(edge.source) || edge.source
|
|
781
846
|
errors.push(`${sourcePath}: declared ${edge.type} target "${edge.target}" was not found`)
|
|
782
847
|
}
|
|
@@ -793,17 +858,24 @@ export function buildKnowledgeGraph({
|
|
|
793
858
|
workspaceRoot,
|
|
794
859
|
repoAccessConfig,
|
|
795
860
|
repoRoots = null,
|
|
861
|
+
repoEntries = null,
|
|
796
862
|
repoAccessConfigPath = 'repo-access config',
|
|
797
863
|
externalRepos = [],
|
|
864
|
+
externalRelationPrefixes = [],
|
|
865
|
+
externalRelationIds = [],
|
|
798
866
|
} = {}) {
|
|
799
867
|
if (!workspaceRoot) throw new Error('workspaceRoot is required')
|
|
800
868
|
const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
801
869
|
const external = new Set(externalRepos)
|
|
802
|
-
const
|
|
870
|
+
const discoveredEntries = repoEntries
|
|
871
|
+
? repoEntries.map((entry) => ({ name: entry.name, path: path.resolve(entry.path) })).sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
872
|
+
: (repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)) : listRepos(resolvedWorkspaceRoot))
|
|
873
|
+
.sort()
|
|
874
|
+
.map((repoRoot) => ({ name: path.basename(repoRoot), path: repoRoot }))
|
|
803
875
|
// External repos are acknowledged but never walked: no document census, no
|
|
804
876
|
// sidecar demands, no projection.
|
|
805
|
-
const roots =
|
|
806
|
-
const repoNames = roots.map((
|
|
877
|
+
const roots = discoveredEntries.filter((entry) => !external.has(entry.name))
|
|
878
|
+
const repoNames = roots.map((entry) => entry.name)
|
|
807
879
|
const accessConfig = repoAccessConfig ?? {
|
|
808
880
|
schema: REPO_ACCESS_SCHEMA,
|
|
809
881
|
defaultReadBoundary: 'team',
|
|
@@ -825,8 +897,9 @@ export function buildKnowledgeGraph({
|
|
|
825
897
|
const workspaceIgnoredSidecars = []
|
|
826
898
|
const repoGraphs = []
|
|
827
899
|
|
|
828
|
-
for (const
|
|
829
|
-
const
|
|
900
|
+
for (const entry of roots) {
|
|
901
|
+
const repoRoot = entry.path
|
|
902
|
+
const repoName = entry.name
|
|
830
903
|
// One batched ignore lookup per repo, shared by every walk below.
|
|
831
904
|
const isIgnored = gitIgnoreFilter(repoRoot)
|
|
832
905
|
const files = walkDocuments(repoRoot, repoRoot, [], isIgnored)
|
|
@@ -858,7 +931,10 @@ export function buildKnowledgeGraph({
|
|
|
858
931
|
workspaceEdges.push(...graph.edges)
|
|
859
932
|
}
|
|
860
933
|
|
|
861
|
-
const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars
|
|
934
|
+
const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars, {
|
|
935
|
+
externalRelationPrefixes,
|
|
936
|
+
externalRelationIds,
|
|
937
|
+
})
|
|
862
938
|
const workspaceGraph = {
|
|
863
939
|
schema: KNOWLEDGE_GRAPH_SCHEMA,
|
|
864
940
|
workspace: path.basename(resolvedWorkspaceRoot),
|
|
@@ -39,7 +39,13 @@ export async function jsonGet(baseUrl, route, params = null, headers = {}) {
|
|
|
39
39
|
const url = new URL(route, `${base.href}/`)
|
|
40
40
|
if (params) appendParams(url, params)
|
|
41
41
|
// @atelier-egress-local-computed
|
|
42
|
-
const response = await fetch(url, {
|
|
42
|
+
const response = await fetch(url, {
|
|
43
|
+
headers: {
|
|
44
|
+
Origin: base.origin,
|
|
45
|
+
'Sec-Fetch-Site': 'same-origin',
|
|
46
|
+
...headers,
|
|
47
|
+
},
|
|
48
|
+
})
|
|
43
49
|
return readJsonResponse(response, route)
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -51,6 +57,8 @@ export async function jsonPost(baseUrl, route, body = {}, headers = {}) {
|
|
|
51
57
|
method: 'POST',
|
|
52
58
|
headers: {
|
|
53
59
|
'Content-Type': 'application/json',
|
|
60
|
+
Origin: base.origin,
|
|
61
|
+
'Sec-Fetch-Site': 'same-origin',
|
|
54
62
|
...headers,
|
|
55
63
|
},
|
|
56
64
|
body: JSON.stringify(body),
|
package/src/index.mjs
CHANGED
|
@@ -52,6 +52,12 @@ export {
|
|
|
52
52
|
contextEnvelope,
|
|
53
53
|
} from './harness/context.mjs'
|
|
54
54
|
|
|
55
|
+
export {
|
|
56
|
+
ATELIER_COLLABORATION_EVENT_SCHEMA,
|
|
57
|
+
createCollaborationEventLedger,
|
|
58
|
+
validateCollaborationEvent,
|
|
59
|
+
} from './collaboration/index.mjs'
|
|
60
|
+
|
|
55
61
|
export {
|
|
56
62
|
buildSupportBundlePreview,
|
|
57
63
|
validateSupportBundlePayload,
|
|
@@ -61,6 +67,12 @@ export {
|
|
|
61
67
|
checkForbiddenEgress,
|
|
62
68
|
} from './egress/forbidden-egress.mjs'
|
|
63
69
|
|
|
70
|
+
export {
|
|
71
|
+
STRUCTURAL_DISCLOSURE_PATTERNS,
|
|
72
|
+
compileDisclosurePatterns,
|
|
73
|
+
scanDisclosureContent,
|
|
74
|
+
} from './disclosure/content-scan.mjs'
|
|
75
|
+
|
|
64
76
|
export {
|
|
65
77
|
BOUNDARY_POLICY_SCHEMA,
|
|
66
78
|
checkBoundaryPolicy,
|
package/src/project/config.mjs
CHANGED
|
@@ -17,6 +17,16 @@ export const LOCAL_OVERLAY_FILES = ['atelier.local.json', 'atelier.workspace.loc
|
|
|
17
17
|
// a way to silence checks on a repo you do manage.
|
|
18
18
|
export const EXTERNAL_REPO_KIND = 'external'
|
|
19
19
|
|
|
20
|
+
export class AtelierDiagnosticError extends Error {
|
|
21
|
+
constructor(code, message, { hint = null, exitCode = 2, cause = null } = {}) {
|
|
22
|
+
super(message, cause ? { cause } : undefined)
|
|
23
|
+
this.name = 'AtelierDiagnosticError'
|
|
24
|
+
this.code = code
|
|
25
|
+
this.hint = hint
|
|
26
|
+
this.exitCode = exitCode
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
export const isExternalRepo = (repo) => firstString(repo?.kind) === EXTERNAL_REPO_KIND
|
|
21
31
|
|
|
22
32
|
// Kept here rather than imported from repo-identity.mjs so config validation stays
|
|
@@ -59,10 +69,19 @@ export function resolvePathValue(value, baseDir) {
|
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export function readJson(file) {
|
|
72
|
+
if (!fs.existsSync(file)) {
|
|
73
|
+
const graphArtifact = path.basename(file) === 'knowledge.graph.json'
|
|
74
|
+
throw new AtelierDiagnosticError('artifact-missing', `required JSON artifact not found: ${file}`, {
|
|
75
|
+
hint: graphArtifact ? 'Run atelier graph with this project config, then retry.' : 'Create or regenerate the named artifact, then retry.',
|
|
76
|
+
})
|
|
77
|
+
}
|
|
62
78
|
try {
|
|
63
79
|
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
64
80
|
} catch (error) {
|
|
65
|
-
throw new
|
|
81
|
+
throw new AtelierDiagnosticError('json-invalid', `JSON is malformed at ${file}: ${error.message}`, {
|
|
82
|
+
hint: 'Repair or regenerate the named JSON file, then retry.',
|
|
83
|
+
cause: error,
|
|
84
|
+
})
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -90,9 +109,30 @@ export function stripProjectConfigArgs(argv = [], prefix = PROJECT_CONFIG_ARG_PR
|
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
export function readProjectConfig(configPath) {
|
|
93
|
-
|
|
112
|
+
let doc
|
|
113
|
+
try {
|
|
114
|
+
doc = readJson(configPath)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof AtelierDiagnosticError) {
|
|
117
|
+
throw new AtelierDiagnosticError(
|
|
118
|
+
error.code === 'artifact-missing' ? 'project-config-missing' : 'project-config-json-invalid',
|
|
119
|
+
error.code === 'artifact-missing'
|
|
120
|
+
? `atelier project config not found: ${configPath}`
|
|
121
|
+
: `atelier project config is malformed: ${configPath}`,
|
|
122
|
+
{
|
|
123
|
+
hint: error.code === 'artifact-missing'
|
|
124
|
+
? 'Pass --project PATH to a tracked atelier.project.json file.'
|
|
125
|
+
: 'Repair the JSON syntax, then run atelier config check with the same --project path.',
|
|
126
|
+
cause: error,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
94
132
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
95
|
-
throw new
|
|
133
|
+
throw new AtelierDiagnosticError('project-config-shape-invalid', `Atelier project config must be a JSON object: ${configPath}`, {
|
|
134
|
+
hint: 'Repair the document shape, then run atelier config check with the same --project path.',
|
|
135
|
+
})
|
|
96
136
|
}
|
|
97
137
|
return doc
|
|
98
138
|
}
|
|
@@ -253,7 +293,9 @@ export function resolveProjectConfig({
|
|
|
253
293
|
missingConfigPath = configPath
|
|
254
294
|
configPath = null
|
|
255
295
|
} else {
|
|
256
|
-
throw new
|
|
296
|
+
throw new AtelierDiagnosticError('project-config-missing', `atelier project config not found: ${configPath || requestedPath}`, {
|
|
297
|
+
hint: 'Pass --project PATH to a tracked atelier.project.json file.',
|
|
298
|
+
})
|
|
257
299
|
}
|
|
258
300
|
}
|
|
259
301
|
|
|
@@ -355,7 +397,9 @@ export function commandProject({ argv = process.argv.slice(2), env = process.env
|
|
|
355
397
|
if (project.configPath != null) {
|
|
356
398
|
const configErrors = validateProjectConfigDoc(project.config)
|
|
357
399
|
if (configErrors.length) {
|
|
358
|
-
throw new
|
|
400
|
+
throw new AtelierDiagnosticError('project-config-contract-invalid', `invalid atelier project config at ${project.configPath}:\n${configErrors.join('\n')}`, {
|
|
401
|
+
hint: 'Fix the listed fields, then run atelier config check with the same --project path.',
|
|
402
|
+
})
|
|
359
403
|
}
|
|
360
404
|
}
|
|
361
405
|
if (project.schema !== PROJECT_CONFIG_SCHEMA) {
|
|
@@ -380,6 +424,11 @@ function gitRootFor(dir) {
|
|
|
380
424
|
return result.status === 0 ? result.stdout.trim() : null
|
|
381
425
|
}
|
|
382
426
|
|
|
427
|
+
function gitPrefixFor(dir) {
|
|
428
|
+
const result = spawnSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], { encoding: 'utf8' })
|
|
429
|
+
return result.status === 0 ? result.stdout.trim() : null
|
|
430
|
+
}
|
|
431
|
+
|
|
383
432
|
function isIgnoredByGit(gitRoot, rel) {
|
|
384
433
|
const result = spawnSync('git', ['-C', gitRoot, 'check-ignore', '-q', rel], { encoding: 'utf8' })
|
|
385
434
|
return result.status === 0
|
|
@@ -388,8 +437,18 @@ function isIgnoredByGit(gitRoot, rel) {
|
|
|
388
437
|
export function ensureLocalState(project, { write = false } = {}) {
|
|
389
438
|
const root = localStateRoot(project)
|
|
390
439
|
const gitRoot = gitRootFor(project.configDir)
|
|
391
|
-
|
|
392
|
-
|
|
440
|
+
// Git owns the repository-relative spelling. Deriving it by comparing
|
|
441
|
+
// filesystem paths breaks when Windows exposes the cwd through an 8.3 short
|
|
442
|
+
// name (RUNNER~1) but Git reports the same root through its long name.
|
|
443
|
+
const gitPrefix = gitRoot ? gitPrefixFor(project.configDir) : null
|
|
444
|
+
const rel = gitRoot && gitPrefix !== null ? `${gitPrefix}${LOCAL_STATE_DIR}` : LOCAL_STATE_DIR
|
|
445
|
+
// A directory-only ignore rule such as `.atelier-local/` is not evaluated
|
|
446
|
+
// consistently by Git for an absent directory on every host. Probe a
|
|
447
|
+
// hypothetical child as well: it proves the directory rule before Atelier
|
|
448
|
+
// creates any local state, including on Git for Windows.
|
|
449
|
+
const ignored = gitRoot && gitPrefix !== null
|
|
450
|
+
? isIgnoredByGit(gitRoot, `${rel}/.atelier-ignore-probe`) || isIgnoredByGit(gitRoot, `${rel}/`) || isIgnoredByGit(gitRoot, rel)
|
|
451
|
+
: gitRoot === null
|
|
393
452
|
const report = {
|
|
394
453
|
root,
|
|
395
454
|
ignored,
|
|
@@ -111,3 +111,17 @@ export function generatedProjectionBasenames(fileClasses = KIT_FILE_CLASSES) {
|
|
|
111
111
|
}
|
|
112
112
|
return names
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// Directory basenames whose complete subtree is generated. Graph walkers can
|
|
116
|
+
// prune these without restating output-root names beside the declaration.
|
|
117
|
+
export function generatedProjectionDirectoryBasenames(fileClasses = KIT_FILE_CLASSES) {
|
|
118
|
+
const names = new Set()
|
|
119
|
+
for (const entry of fileClasses) {
|
|
120
|
+
if (entry.class !== GENERATED_PROJECTION) continue
|
|
121
|
+
const normalized = normalizeRelPath(entry.pattern)
|
|
122
|
+
if (!normalized.endsWith('/**')) continue
|
|
123
|
+
const parent = normalized.slice(0, -3).split('/').at(-1)
|
|
124
|
+
if (parent && !parent.includes('*')) names.add(parent)
|
|
125
|
+
}
|
|
126
|
+
return names
|
|
127
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
export function nativePathFromFileUrl(value) {
|
|
5
|
+
return fileURLToPath(value instanceof URL ? value : new URL(value))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function packageRootFrom(moduleUrl) {
|
|
9
|
+
return path.resolve(nativePathFromFileUrl(new URL('../..', moduleUrl)))
|
|
10
|
+
}
|
|
@@ -4,27 +4,50 @@ export const normalizeRelPath = (value) =>
|
|
|
4
4
|
String(value ?? '')
|
|
5
5
|
.replaceAll('\\', '/')
|
|
6
6
|
.replace(/^\.\/+/, '')
|
|
7
|
+
.replace(/\/{2,}/g, '/')
|
|
7
8
|
|
|
8
9
|
const escapeRe = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
function globSource(pattern) {
|
|
12
|
+
let source = ''
|
|
13
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
14
|
+
const char = pattern[index]
|
|
15
|
+
if (char !== '*') {
|
|
16
|
+
source += escapeRe(char)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const globstar = pattern[index + 1] === '*'
|
|
21
|
+
if (!globstar) {
|
|
22
|
+
source += '[^/]*'
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
index += 1
|
|
27
|
+
if (pattern[index + 1] === '/') {
|
|
28
|
+
index += 1
|
|
29
|
+
source += '(?:.*/)?'
|
|
30
|
+
} else if (source.endsWith('/')) {
|
|
31
|
+
source = source.slice(0, -1)
|
|
32
|
+
source += '(?:/.*)?'
|
|
33
|
+
} else {
|
|
34
|
+
source += '.*'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return source
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Shared by the boundary policy and the file-class resolver. The dialect is
|
|
41
|
+
// intentionally portable: case does not depend on the host filesystem, `*`
|
|
42
|
+
// stays inside one segment, and `**` crosses segments. Slashless patterns keep
|
|
43
|
+
// their historical match-base behavior, so `*.md` applies at any depth without
|
|
44
|
+
// making `private/*.md` consume nested directories.
|
|
13
45
|
export function matchesPathPattern(pattern, rel) {
|
|
14
46
|
const normalizedPattern = normalizeRelPath(pattern)
|
|
15
47
|
const normalizedRel = normalizeRelPath(rel)
|
|
16
|
-
if (normalizedPattern
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
if (normalizedPattern.startsWith('**/')) {
|
|
21
|
-
const tail = normalizedPattern.slice(3)
|
|
22
|
-
return normalizedRel === tail || normalizedRel.endsWith(`/${tail}`) || matchesPathPattern(tail, normalizedRel)
|
|
23
|
-
}
|
|
24
|
-
if (normalizedPattern.includes('*')) {
|
|
25
|
-
return new RegExp(`^${normalizedPattern.split('*').map(escapeRe).join('.*')}$`).test(normalizedRel)
|
|
26
|
-
}
|
|
27
|
-
return false
|
|
48
|
+
if (!normalizedPattern || !normalizedRel) return false
|
|
49
|
+
const candidate = normalizedPattern.includes('/') ? normalizedRel : path.posix.basename(normalizedRel)
|
|
50
|
+
return new RegExp(`^${globSource(normalizedPattern)}$`, 'i').test(candidate)
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
export const basenameOf = (pattern) => path.posix.basename(normalizeRelPath(pattern))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
function escapes(root, candidate) {
|
|
5
|
+
const relative = path.relative(root, candidate)
|
|
6
|
+
return relative.startsWith('..') || path.isAbsolute(relative)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function lstatIfPresent(file) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.lstatSync(file)
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error?.code === 'ENOENT') return null
|
|
14
|
+
throw error
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ensureContainedPrivateDirectory({ workspaceRoot, directory, label = 'private state directory' }) {
|
|
19
|
+
const lexicalRoot = path.resolve(workspaceRoot)
|
|
20
|
+
const realRoot = fs.realpathSync(lexicalRoot)
|
|
21
|
+
const requested = path.resolve(directory)
|
|
22
|
+
let relative = path.relative(lexicalRoot, requested)
|
|
23
|
+
if (escapes(lexicalRoot, requested)) relative = path.relative(realRoot, requested)
|
|
24
|
+
if (relative === '' || escapes(realRoot, path.join(realRoot, relative))) {
|
|
25
|
+
if (relative === '') return realRoot
|
|
26
|
+
throw new Error(`${label} escapes workspace`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let current = realRoot
|
|
30
|
+
for (const segment of relative.split(path.sep)) {
|
|
31
|
+
current = path.join(current, segment)
|
|
32
|
+
let stat = lstatIfPresent(current)
|
|
33
|
+
if (!stat) {
|
|
34
|
+
fs.mkdirSync(current, { mode: 0o700 })
|
|
35
|
+
stat = fs.lstatSync(current)
|
|
36
|
+
}
|
|
37
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
38
|
+
throw new Error(`${label} contains a redirected or non-directory component`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const resolved = fs.realpathSync(current)
|
|
42
|
+
if (escapes(realRoot, resolved)) throw new Error(`${label} escapes workspace`)
|
|
43
|
+
try {
|
|
44
|
+
fs.chmodSync(resolved, 0o700)
|
|
45
|
+
} catch {
|
|
46
|
+
// Best effort on filesystems that do not support chmod.
|
|
47
|
+
}
|
|
48
|
+
return resolved
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function openRegularFileNoFollow(file, flags = fs.constants.O_RDONLY, mode) {
|
|
52
|
+
const before = lstatIfPresent(file)
|
|
53
|
+
if (before && (before.isSymbolicLink() || !before.isFile())) {
|
|
54
|
+
throw new Error('state leaf is not a regular file')
|
|
55
|
+
}
|
|
56
|
+
const descriptor = fs.openSync(file, flags | (fs.constants.O_NOFOLLOW ?? 0), mode)
|
|
57
|
+
try {
|
|
58
|
+
const opened = fs.fstatSync(descriptor)
|
|
59
|
+
if (!opened.isFile()) throw new Error('state leaf is not a regular file')
|
|
60
|
+
// Windows does not expose O_NOFOLLOW. Refuse a pre-existing redirected
|
|
61
|
+
// leaf before open and bind the opened descriptor back to that same file
|
|
62
|
+
// identity where the filesystem supplies stable device/inode values.
|
|
63
|
+
if (before && before.ino !== 0 && (before.dev !== opened.dev || before.ino !== opened.ino)) {
|
|
64
|
+
throw new Error('state leaf changed while opening')
|
|
65
|
+
}
|
|
66
|
+
return descriptor
|
|
67
|
+
} catch (error) {
|
|
68
|
+
fs.closeSync(descriptor)
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readRegularTextNoFollow(file) {
|
|
74
|
+
const descriptor = openRegularFileNoFollow(file)
|
|
75
|
+
try {
|
|
76
|
+
return fs.readFileSync(descriptor, 'utf8')
|
|
77
|
+
} finally {
|
|
78
|
+
fs.closeSync(descriptor)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function atomicReplacePrivateText(file, text, mode = 0o600) {
|
|
83
|
+
const existing = lstatIfPresent(file)
|
|
84
|
+
if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
|
|
85
|
+
throw new Error('state leaf is not a regular file')
|
|
86
|
+
}
|
|
87
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
88
|
+
let descriptor
|
|
89
|
+
try {
|
|
90
|
+
descriptor = openRegularFileNoFollow(
|
|
91
|
+
tmp,
|
|
92
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
93
|
+
mode,
|
|
94
|
+
)
|
|
95
|
+
fs.writeFileSync(descriptor, text)
|
|
96
|
+
fs.fsyncSync(descriptor)
|
|
97
|
+
fs.fchmodSync(descriptor, mode)
|
|
98
|
+
fs.closeSync(descriptor)
|
|
99
|
+
descriptor = null
|
|
100
|
+
fs.renameSync(tmp, file)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (descriptor != null) fs.closeSync(descriptor)
|
|
103
|
+
try {
|
|
104
|
+
fs.unlinkSync(tmp)
|
|
105
|
+
} catch {
|
|
106
|
+
// The temporary file may not have been created.
|
|
107
|
+
}
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|