@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -12
  3. package/contracts/public-api-baseline.json +57 -0
  4. package/docs/assurance-controls.md +39 -0
  5. package/docs/atelier-runtime.md +15 -0
  6. package/docs/blocks/claims.md +15 -9
  7. package/docs/design.md +12 -6
  8. package/docs/install.md +26 -4
  9. package/docs/knowledge-graph.md +8 -4
  10. package/docs/local-services.md +101 -0
  11. package/docs/release-engineering.md +75 -10
  12. package/docs/repo-boundary-guard.md +12 -2
  13. package/docs/upgrade.md +25 -2
  14. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  15. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  16. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  17. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  18. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  19. package/package.json +12 -5
  20. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  21. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  22. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  23. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  24. package/src/boundary/content-rules.mjs +278 -20
  25. package/src/boundary/policy.mjs +150 -60
  26. package/src/cli/execute-command.mjs +36 -0
  27. package/src/cli/run.mjs +17 -7
  28. package/src/collaboration/event-ledger.mjs +365 -0
  29. package/src/collaboration/index.mjs +17 -0
  30. package/src/collaboration/proposals.mjs +265 -65
  31. package/src/commands/attestation.mjs +20 -6
  32. package/src/commands/disclosure.mjs +133 -0
  33. package/src/commands/distribution.mjs +2 -1
  34. package/src/commands/extension-pack.mjs +2 -1
  35. package/src/commands/init.mjs +2 -1
  36. package/src/commands/server.mjs +1 -4
  37. package/src/disclosure/content-scan.mjs +193 -0
  38. package/src/egress/check.mjs +7 -38
  39. package/src/egress/forbidden-egress.mjs +32 -18
  40. package/src/graph/graph.mjs +112 -314
  41. package/src/graph/knowledge-graph.mjs +94 -18
  42. package/src/harness/context-client.mjs +9 -1
  43. package/src/index.mjs +12 -0
  44. package/src/project/config.mjs +66 -7
  45. package/src/project/file-class.mjs +14 -0
  46. package/src/project/package-root.mjs +10 -0
  47. package/src/project/path-match.mjs +38 -15
  48. package/src/project/private-state.mjs +110 -0
  49. package/src/server/local-sidecar.mjs +81 -59
  50. package/src/server/security.mjs +89 -4
  51. package/src/server/server.mjs +3 -2
  52. package/src/support/feedback-report.mjs +4 -3
  53. 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/g, '\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
- relations[key] = Array.isArray(items) ? asStringArray(items) : items
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
- metadata = markdownMetadata(raw)
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}: Markdown kg block must declare kg.id`)
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}: legacy kg.visibility is reserved for runtime export; use kg.audience`)
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
- if (!seenIds.has(edge.target)) {
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 discovered = repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)).sort() : listRepos(resolvedWorkspaceRoot)
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 = discovered.filter((repoRoot) => !external.has(path.basename(repoRoot)))
806
- const repoNames = roots.map((repoRoot) => path.basename(repoRoot))
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 repoRoot of roots) {
829
- const repoName = path.basename(repoRoot)
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, { headers })
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,
@@ -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 Error(`invalid JSON at ${file}: ${error.message}`)
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
- const doc = readJson(configPath)
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 Error(`atelier project config must be a JSON object: ${configPath}`)
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 Error(`atelier project config not found: ${configPath || requestedPath}`)
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 Error(`invalid atelier project config at ${project.configPath}:\n${configErrors.join('\n')}`)
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
- const rel = gitRoot ? path.relative(gitRoot, root).split(path.sep).join('/') : LOCAL_STATE_DIR
392
- const ignored = gitRoot ? isIgnoredByGit(gitRoot, `${rel}/`) || isIgnoredByGit(gitRoot, rel) : true
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
- // Shared by the boundary policy and the file-class resolver. Both used to carry
11
- // their own copy; two glob dialects in one kit is exactly the drift this file exists
12
- // to prevent.
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 === normalizedRel) return true
17
- if (normalizedPattern.endsWith('/**')) {
18
- return normalizedRel === normalizedPattern.slice(0, -3) || normalizedRel.startsWith(normalizedPattern.slice(0, -2))
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
+ }