@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.6

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 (64) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +61 -18
  3. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  4. package/contracts/public-api-baseline.json +57 -0
  5. package/docs/assurance-controls.md +41 -0
  6. package/docs/atelier-runtime.md +28 -2
  7. package/docs/atelier-sync.md +171 -0
  8. package/docs/blocks/claims.md +23 -12
  9. package/docs/blocks/will-not-do.md +9 -3
  10. package/docs/design.md +12 -6
  11. package/docs/install.md +26 -4
  12. package/docs/knowledge-graph.md +8 -4
  13. package/docs/local-services.md +101 -0
  14. package/docs/release-engineering.md +86 -12
  15. package/docs/repo-boundary-guard.md +12 -2
  16. package/docs/upgrade.md +40 -2
  17. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  18. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  19. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  20. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  21. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  22. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  23. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  24. package/package.json +16 -5
  25. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  26. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  27. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  28. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  29. package/src/boundary/content-rules.mjs +283 -20
  30. package/src/boundary/policy.mjs +162 -72
  31. package/src/cli/execute-command.mjs +36 -0
  32. package/src/cli/run.mjs +35 -7
  33. package/src/collaboration/event-ledger.mjs +365 -0
  34. package/src/collaboration/index.mjs +17 -0
  35. package/src/collaboration/proposals.mjs +265 -65
  36. package/src/commands/attestation.mjs +20 -6
  37. package/src/commands/disclosure.mjs +133 -0
  38. package/src/commands/distribution.mjs +2 -1
  39. package/src/commands/extension-pack.mjs +2 -1
  40. package/src/commands/init.mjs +2 -1
  41. package/src/commands/server.mjs +1 -4
  42. package/src/commands/sync.mjs +100 -0
  43. package/src/contracts/corpus.mjs +6 -0
  44. package/src/disclosure/content-scan.mjs +193 -0
  45. package/src/egress/check.mjs +7 -38
  46. package/src/egress/forbidden-egress.mjs +32 -18
  47. package/src/graph/graph.mjs +112 -314
  48. package/src/graph/knowledge-graph.mjs +94 -18
  49. package/src/harness/context-client.mjs +9 -1
  50. package/src/index.mjs +41 -0
  51. package/src/project/config.mjs +89 -28
  52. package/src/project/file-class.mjs +14 -0
  53. package/src/project/package-root.mjs +10 -0
  54. package/src/project/path-match.mjs +38 -15
  55. package/src/project/private-state.mjs +110 -0
  56. package/src/runtime/git-adapter.mjs +189 -0
  57. package/src/runtime/local-state.mjs +439 -0
  58. package/src/runtime/repository-observation.mjs +491 -0
  59. package/src/runtime/supervisor.mjs +788 -0
  60. package/src/server/local-sidecar.mjs +81 -59
  61. package/src/server/security.mjs +89 -4
  62. package/src/server/server.mjs +3 -2
  63. package/src/support/feedback-report.mjs +4 -3
  64. 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,
@@ -86,3 +98,32 @@ export {
86
98
  export {
87
99
  analysisAdapterDryRun,
88
100
  } from './analysis/adapter.mjs'
101
+
102
+ export {
103
+ GitCommandError,
104
+ classifyRemoteAuthentication,
105
+ inspectGitEngine,
106
+ parseGitVersion,
107
+ resolveGitExecutable,
108
+ runGit,
109
+ sanitizeRemoteUrl,
110
+ } from './runtime/git-adapter.mjs'
111
+
112
+ export {
113
+ ATELIER_REPOSITORY_OBSERVATION_SCHEMA,
114
+ classifyFilesystemRoot,
115
+ observeRepository,
116
+ resolveRepositoryRoot,
117
+ validateRepositoryObservation,
118
+ } from './runtime/repository-observation.mjs'
119
+
120
+ export {
121
+ ATELIER_COMMIT_PLAN_SCHEMA,
122
+ enrollRepository,
123
+ executeUserConfirmedCommit,
124
+ operationTrace,
125
+ planUserConfirmedCommit,
126
+ reconcileRepository,
127
+ runtimeStatus,
128
+ setRepositoryPaused,
129
+ } from './runtime/supervisor.mjs'
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { spawnSync } from 'node:child_process'
4
+ import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
4
5
 
5
6
  export const PROJECT_CONFIG_ARG_PREFIX = '--project-config='
6
7
  export const PROJECT_CONFIG_ENV = 'MNSTRY_ATELIER_PROJECT_CONFIG'
@@ -17,6 +18,16 @@ export const LOCAL_OVERLAY_FILES = ['atelier.local.json', 'atelier.workspace.loc
17
18
  // a way to silence checks on a repo you do manage.
18
19
  export const EXTERNAL_REPO_KIND = 'external'
19
20
 
21
+ export class AtelierDiagnosticError extends Error {
22
+ constructor(code, message, { hint = null, exitCode = 2, cause = null } = {}) {
23
+ super(message, cause ? { cause } : undefined)
24
+ this.name = 'AtelierDiagnosticError'
25
+ this.code = code
26
+ this.hint = hint
27
+ this.exitCode = exitCode
28
+ }
29
+ }
30
+
20
31
  export const isExternalRepo = (repo) => firstString(repo?.kind) === EXTERNAL_REPO_KIND
21
32
 
22
33
  // Kept here rather than imported from repo-identity.mjs so config validation stays
@@ -59,10 +70,19 @@ export function resolvePathValue(value, baseDir) {
59
70
  }
60
71
 
61
72
  export function readJson(file) {
73
+ if (!fs.existsSync(file)) {
74
+ const graphArtifact = path.basename(file) === 'knowledge.graph.json'
75
+ throw new AtelierDiagnosticError('artifact-missing', `required JSON artifact not found: ${file}`, {
76
+ hint: graphArtifact ? 'Run atelier graph with this project config, then retry.' : 'Create or regenerate the named artifact, then retry.',
77
+ })
78
+ }
62
79
  try {
63
80
  return JSON.parse(fs.readFileSync(file, 'utf8'))
64
81
  } catch (error) {
65
- throw new Error(`invalid JSON at ${file}: ${error.message}`)
82
+ throw new AtelierDiagnosticError('json-invalid', `JSON is malformed at ${file}: ${error.message}`, {
83
+ hint: 'Repair or regenerate the named JSON file, then retry.',
84
+ cause: error,
85
+ })
66
86
  }
67
87
  }
68
88
 
@@ -90,9 +110,30 @@ export function stripProjectConfigArgs(argv = [], prefix = PROJECT_CONFIG_ARG_PR
90
110
  }
91
111
 
92
112
  export function readProjectConfig(configPath) {
93
- const doc = readJson(configPath)
113
+ let doc
114
+ try {
115
+ doc = readJson(configPath)
116
+ } catch (error) {
117
+ if (error instanceof AtelierDiagnosticError) {
118
+ throw new AtelierDiagnosticError(
119
+ error.code === 'artifact-missing' ? 'project-config-missing' : 'project-config-json-invalid',
120
+ error.code === 'artifact-missing'
121
+ ? `atelier project config not found: ${configPath}`
122
+ : `atelier project config is malformed: ${configPath}`,
123
+ {
124
+ hint: error.code === 'artifact-missing'
125
+ ? 'Pass --project PATH to a tracked atelier.project.json file.'
126
+ : 'Repair the JSON syntax, then run atelier config check with the same --project path.',
127
+ cause: error,
128
+ },
129
+ )
130
+ }
131
+ throw error
132
+ }
94
133
  if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
95
- throw new Error(`atelier project config must be a JSON object: ${configPath}`)
134
+ throw new AtelierDiagnosticError('project-config-shape-invalid', `Atelier project config must be a JSON object: ${configPath}`, {
135
+ hint: 'Repair the document shape, then run atelier config check with the same --project path.',
136
+ })
96
137
  }
97
138
  return doc
98
139
  }
@@ -155,25 +196,25 @@ function overlayRepoPath(overlay, repoName) {
155
196
  return firstString(direct, repo.path, repo.localPath)
156
197
  }
157
198
 
158
- function resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay, cliRepoPaths }) {
199
+ function resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay, cliRepoPaths, gitExecutable, env }) {
159
200
  if (repoName && cliRepoPaths.has(repoName)) return cliRepoPaths.get(repoName)
160
201
  const overlayPath = overlayRepoPath(overlay, repoName)
161
202
  const fromOverlay = resolvePathValue(overlayPath, configDir)
162
203
  if (fromOverlay) return fromOverlay
163
204
  const fromConfig = resolvePathValue(repo.path, configDir) || resolvePathValue(repo.path, workspaceRoot)
164
205
  if (fromConfig) return fromConfig
165
- return discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot })
206
+ return discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot, gitExecutable, env })
166
207
  }
167
208
 
168
- function repoPathSource({ repo, repoName, overlay, cliRepoPaths, workspaceRoot, configDir }) {
209
+ function repoPathSource({ repo, repoName, overlay, cliRepoPaths, workspaceRoot, configDir, gitExecutable, env }) {
169
210
  if (repoName && cliRepoPaths.has(repoName)) return 'cli'
170
211
  if (overlayRepoPath(overlay, repoName)) return 'local-overlay'
171
212
  if (firstString(repo.path)) return 'tracked-config'
172
- if (discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot })) return 'sibling-discovery'
213
+ if (discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot, gitExecutable, env })) return 'sibling-discovery'
173
214
  return null
174
215
  }
175
216
 
176
- function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot }) {
217
+ function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot, gitExecutable = 'git', env = process.env }) {
177
218
  if (!repoName) return null
178
219
  const candidates = [
179
220
  path.join(workspaceRoot, repoName),
@@ -182,14 +223,14 @@ function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot }) {
182
223
  ]
183
224
  for (const candidate of candidates) {
184
225
  if (!fs.existsSync(candidate)) continue
185
- if (repo.remote && !gitRemoteMatches(candidate, repo.remote)) continue
226
+ if (repo.remote && !gitRemoteMatches(candidate, repo.remote, { gitExecutable, env })) continue
186
227
  return path.resolve(candidate)
187
228
  }
188
229
  return null
189
230
  }
190
231
 
191
- function gitRemoteMatches(repoPath, expected) {
192
- const result = spawnSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
232
+ function gitRemoteMatches(repoPath, expected, { gitExecutable = 'git', env = process.env } = {}) {
233
+ const result = spawnSync(gitExecutable, ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
193
234
  if (result.status !== 0) return false
194
235
  const actual = normalizeRemote(result.stdout.trim())
195
236
  return actual === normalizeRemote(expected)
@@ -203,9 +244,9 @@ function normalizeRemote(value) {
203
244
  .toLowerCase()
204
245
  }
205
246
 
206
- export function gitRemoteUrl(repoPath) {
247
+ export function gitRemoteUrl(repoPath, { gitExecutable = 'git', env = process.env } = {}) {
207
248
  if (!repoPath || !fs.existsSync(repoPath)) return null
208
- const result = spawnSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
249
+ const result = spawnSync(gitExecutable, ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
209
250
  return result.status === 0 ? firstString(result.stdout) : null
210
251
  }
211
252
 
@@ -228,6 +269,7 @@ export function resolveProjectConfig({
228
269
  env = process.env,
229
270
  cwd = process.cwd(),
230
271
  configArgPrefix = PROJECT_CONFIG_ARG_PREFIX,
272
+ gitExecutable = 'git',
231
273
  configEnv = PROJECT_CONFIG_ENV,
232
274
  defaults = {},
233
275
  } = {}) {
@@ -253,7 +295,9 @@ export function resolveProjectConfig({
253
295
  missingConfigPath = configPath
254
296
  configPath = null
255
297
  } else {
256
- throw new Error(`atelier project config not found: ${configPath || requestedPath}`)
298
+ throw new AtelierDiagnosticError('project-config-missing', `atelier project config not found: ${configPath || requestedPath}`, {
299
+ hint: 'Pass --project PATH to a tracked atelier.project.json file.',
300
+ })
257
301
  }
258
302
  }
259
303
 
@@ -331,7 +375,7 @@ export function resolveProjectConfig({
331
375
  localOverlay,
332
376
  repos: (Array.isArray(config.repos) ? config.repos : []).map((repo) => {
333
377
  const repoName = firstString(repo.name) || (repo.path ? path.basename(repo.path) : null)
334
- const repoPath = resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay: localOverlay.overlay, cliRepoPaths })
378
+ const repoPath = resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay: localOverlay.overlay, cliRepoPaths, gitExecutable, env })
335
379
  const external = isExternalRepo(repo)
336
380
  return {
337
381
  ...repo,
@@ -339,23 +383,25 @@ export function resolveProjectConfig({
339
383
  path: repoPath,
340
384
  external,
341
385
  readBoundary: external ? null : firstString(repo.readBoundary) || 'team',
342
- pathSource: repoPath ? repoPathSource({ repo, repoName, overlay: localOverlay.overlay, cliRepoPaths, workspaceRoot, configDir }) : null,
386
+ pathSource: repoPath ? repoPathSource({ repo, repoName, overlay: localOverlay.overlay, cliRepoPaths, workspaceRoot, configDir, gitExecutable, env }) : null,
343
387
  }
344
388
  }),
345
389
  }
346
- resolved.localState = ensureLocalState(resolved, { write: true })
390
+ resolved.localState = ensureLocalState(resolved, { write: true, gitExecutable, env })
347
391
  return resolved
348
392
  }
349
393
 
350
- export function commandProject({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd() } = {}) {
351
- const project = resolveProjectConfig({ argv, env, cwd })
394
+ export function commandProject({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd(), gitExecutable = 'git' } = {}) {
395
+ const project = resolveProjectConfig({ argv, env, cwd, gitExecutable })
352
396
  // Fail closed at CLI entry, but only when a real config file was loaded; the
353
397
  // defaults/no-file path resolves with an empty config that would spuriously
354
398
  // fail document validation.
355
399
  if (project.configPath != null) {
356
400
  const configErrors = validateProjectConfigDoc(project.config)
357
401
  if (configErrors.length) {
358
- throw new Error(`invalid atelier project config at ${project.configPath}:\n${configErrors.join('\n')}`)
402
+ throw new AtelierDiagnosticError('project-config-contract-invalid', `invalid atelier project config at ${project.configPath}:\n${configErrors.join('\n')}`, {
403
+ hint: 'Fix the listed fields, then run atelier config check with the same --project path.',
404
+ })
359
405
  }
360
406
  }
361
407
  if (project.schema !== PROJECT_CONFIG_SCHEMA) {
@@ -375,21 +421,36 @@ export function localStateRoot(projectOrDir) {
375
421
  return path.join(configDir || process.cwd(), LOCAL_STATE_DIR)
376
422
  }
377
423
 
378
- function gitRootFor(dir) {
379
- const result = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
424
+ function gitRootFor(dir, { gitExecutable = 'git', env = process.env } = {}) {
425
+ const result = spawnSync(gitExecutable, ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
426
+ return result.status === 0 ? result.stdout.trim() : null
427
+ }
428
+
429
+ function gitPrefixFor(dir, { gitExecutable = 'git', env = process.env } = {}) {
430
+ const result = spawnSync(gitExecutable, ['-C', dir, 'rev-parse', '--show-prefix'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
380
431
  return result.status === 0 ? result.stdout.trim() : null
381
432
  }
382
433
 
383
- function isIgnoredByGit(gitRoot, rel) {
384
- const result = spawnSync('git', ['-C', gitRoot, 'check-ignore', '-q', rel], { encoding: 'utf8' })
434
+ function isIgnoredByGit(gitRoot, rel, { gitExecutable = 'git', env = process.env } = {}) {
435
+ const result = spawnSync(gitExecutable, ['-C', gitRoot, 'check-ignore', '-q', rel], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
385
436
  return result.status === 0
386
437
  }
387
438
 
388
- export function ensureLocalState(project, { write = false } = {}) {
439
+ export function ensureLocalState(project, { write = false, gitExecutable = 'git', env = process.env } = {}) {
389
440
  const root = localStateRoot(project)
390
- 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
441
+ const gitRoot = gitRootFor(project.configDir, { gitExecutable, env })
442
+ // Git owns the repository-relative spelling. Deriving it by comparing
443
+ // filesystem paths breaks when Windows exposes the cwd through an 8.3 short
444
+ // name (RUNNER~1) but Git reports the same root through its long name.
445
+ const gitPrefix = gitRoot ? gitPrefixFor(project.configDir, { gitExecutable, env }) : null
446
+ const rel = gitRoot && gitPrefix !== null ? `${gitPrefix}${LOCAL_STATE_DIR}` : LOCAL_STATE_DIR
447
+ // A directory-only ignore rule such as `.atelier-local/` is not evaluated
448
+ // consistently by Git for an absent directory on every host. Probe a
449
+ // hypothetical child as well: it proves the directory rule before Atelier
450
+ // creates any local state, including on Git for Windows.
451
+ const ignored = gitRoot && gitPrefix !== null
452
+ ? isIgnoredByGit(gitRoot, `${rel}/.atelier-ignore-probe`, { gitExecutable, env }) || isIgnoredByGit(gitRoot, `${rel}/`, { gitExecutable, env }) || isIgnoredByGit(gitRoot, rel, { gitExecutable, env })
453
+ : gitRoot === null
393
454
  const report = {
394
455
  root,
395
456
  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))