@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.
- package/CHANGELOG.md +77 -0
- package/README.md +61 -18
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +41 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +171 -0
- package/docs/blocks/claims.md +23 -12
- package/docs/blocks/will-not-do.md +9 -3
- 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 +86 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/upgrade.md +40 -2
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- 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 +16 -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 +283 -20
- package/src/boundary/policy.mjs +162 -72
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/run.mjs +35 -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/commands/sync.mjs +100 -0
- package/src/contracts/corpus.mjs +6 -0
- 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 +41 -0
- package/src/project/config.mjs +89 -28
- 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/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +788 -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
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { validateJsonSchema } from '../export/atelier-export-contract.mjs'
|
|
5
|
+
import { readRegularTextNoFollow } from '../project/private-state.mjs'
|
|
6
|
+
import {
|
|
7
|
+
classifyRemoteAuthentication,
|
|
8
|
+
gitText,
|
|
9
|
+
inspectGitEngine,
|
|
10
|
+
parseNullConfig,
|
|
11
|
+
runGit,
|
|
12
|
+
sanitizeRemoteUrl,
|
|
13
|
+
} from './git-adapter.mjs'
|
|
14
|
+
|
|
15
|
+
export const ATELIER_REPOSITORY_OBSERVATION_SCHEMA = 'atelier-repository-observation@v1'
|
|
16
|
+
export const ATELIER_REPOSITORY_OBSERVATION_MAX_ENTRIES = 4096
|
|
17
|
+
const OBSERVATION_CONTRACT = JSON.parse(fs.readFileSync(new URL('../../contracts/atelier-repository-observation.v1.schema.json', import.meta.url), 'utf8'))
|
|
18
|
+
|
|
19
|
+
const CLOUD_PATH_PATTERNS = [
|
|
20
|
+
{ code: 'provider-managed-onedrive', pattern: /(?:^|[\\/])OneDrive(?:\s*-[^\\/]+)?(?:[\\/]|$)/i },
|
|
21
|
+
{ code: 'provider-managed-dropbox', pattern: /(?:^|[\\/])Dropbox(?:[\\/]|$)/i },
|
|
22
|
+
{ code: 'provider-managed-google-drive', pattern: /(?:^|[\\/])Google Drive(?:[\\/]|$)/i },
|
|
23
|
+
{ code: 'provider-managed-icloud', pattern: /(?:^|[\\/])(?:iCloud Drive|Mobile Documents)(?:[\\/]|$)/i },
|
|
24
|
+
{ code: 'provider-managed-cloudstorage', pattern: /(?:^|[\\/])Library[\\/]CloudStorage(?:[\\/]|$)/i },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function normalizeRelative(value) {
|
|
28
|
+
return String(value || '').replaceAll('\\', '/').replace(/^\.\//, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sha256(value) {
|
|
32
|
+
return crypto.createHash('sha256').update(value).digest('hex')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function firstLine(value) {
|
|
36
|
+
return String(value || '').split(/\r?\n/)[0].trim()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function optionalText(gitExecutable, repoRoot, args, { failures = null, label = args[0], allowMissing = false } = {}) {
|
|
40
|
+
const result = runGit(gitExecutable, repoRoot, args, { allowFailure: true })
|
|
41
|
+
if (result.ok) return result.stdout.trim()
|
|
42
|
+
if (!allowMissing && failures) failures.push(blocker('observation-evidence-unavailable', `required Git evidence is unavailable: ${label}`, { probe: label }))
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function boolConfig(config, key) {
|
|
47
|
+
const item = config.find((entry) => entry.key.toLowerCase() === key.toLowerCase())
|
|
48
|
+
return /^(?:true|yes|on|1)$/i.test(item?.value || '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeConfigKey(key) {
|
|
52
|
+
const segments = String(key || '').split('.')
|
|
53
|
+
if (segments.length < 3) return String(key || '').toLowerCase()
|
|
54
|
+
const section = segments.shift().toLowerCase()
|
|
55
|
+
const variable = segments.pop().toLowerCase()
|
|
56
|
+
return `${section}.[subsection].${variable}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function configValues(config, pattern, { includeValue = false } = {}) {
|
|
60
|
+
return config.filter((entry) => pattern.test(entry.key)).map((entry) => includeValue ? { key: safeConfigKey(entry.key), value: entry.value } : { key: safeConfigKey(entry.key) })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function classifyFilesystemRoot(candidate, { platform = process.platform } = {}) {
|
|
64
|
+
const requested = String(candidate || '')
|
|
65
|
+
if (!requested) return { supported: false, code: 'path-missing', requested, resolved: null }
|
|
66
|
+
if (/^(?:\\\\|\/\/)/.test(requested)) {
|
|
67
|
+
return { supported: false, code: 'network-unc-root', requested, resolved: null }
|
|
68
|
+
}
|
|
69
|
+
if (platform === 'linux' && /^\/mnt\/[a-z](?:\/|$)/i.test(requested)) {
|
|
70
|
+
return { supported: false, code: 'wsl-cross-boundary-root', requested, resolved: null }
|
|
71
|
+
}
|
|
72
|
+
const cloud = CLOUD_PATH_PATTERNS.find(({ pattern }) => pattern.test(requested))
|
|
73
|
+
if (cloud) return { supported: false, code: cloud.code, requested, resolved: null }
|
|
74
|
+
|
|
75
|
+
let resolved
|
|
76
|
+
try {
|
|
77
|
+
resolved = fs.realpathSync.native(requested)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { supported: false, code: 'path-unresolvable', requested, resolved: null, detail: error.code || error.message }
|
|
80
|
+
}
|
|
81
|
+
const resolvedCloud = CLOUD_PATH_PATTERNS.find(({ pattern }) => pattern.test(resolved))
|
|
82
|
+
if (resolvedCloud) return { supported: false, code: resolvedCloud.code, requested, resolved }
|
|
83
|
+
if (platform === 'darwin' && /^\/Volumes\//.test(resolved)) {
|
|
84
|
+
return { supported: false, code: 'external-volume-unclassified', requested, resolved }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let statfs = null
|
|
88
|
+
try {
|
|
89
|
+
const value = fs.statfsSync(resolved)
|
|
90
|
+
statfs = { type: String(value.type), blockSize: Number(value.bsize), blocks: Number(value.blocks) }
|
|
91
|
+
} catch {
|
|
92
|
+
// Filesystem metadata is useful evidence, but absence is not proof of a
|
|
93
|
+
// remote root. The lexical refusal boundary remains deterministic.
|
|
94
|
+
}
|
|
95
|
+
return { supported: true, code: 'local-filesystem', requested, resolved, statfs }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveRepositoryRoot(candidate, gitExecutable) {
|
|
99
|
+
const filesystem = classifyFilesystemRoot(candidate)
|
|
100
|
+
if (!filesystem.supported) return { ok: false, filesystem, root: filesystem.resolved }
|
|
101
|
+
const rootResult = runGit(gitExecutable, filesystem.resolved, ['rev-parse', '--show-toplevel'], { allowFailure: true })
|
|
102
|
+
if (!rootResult.ok) return { ok: false, filesystem, root: null, error: 'path is not inside a Git worktree' }
|
|
103
|
+
let root
|
|
104
|
+
try {
|
|
105
|
+
root = fs.realpathSync.native(rootResult.stdout.trim())
|
|
106
|
+
} catch {
|
|
107
|
+
root = fs.realpathSync(rootResult.stdout.trim())
|
|
108
|
+
}
|
|
109
|
+
const rootFilesystem = classifyFilesystemRoot(root)
|
|
110
|
+
if (!rootFilesystem.supported) return { ok: false, filesystem: rootFilesystem, root }
|
|
111
|
+
return { ok: true, filesystem: rootFilesystem, root }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parsePorcelainStatus(text) {
|
|
115
|
+
const records = String(text || '').split('\0')
|
|
116
|
+
const entries = []
|
|
117
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
118
|
+
const record = records[index]
|
|
119
|
+
if (!record) continue
|
|
120
|
+
const code = record.slice(0, 2)
|
|
121
|
+
const item = { code, path: normalizeRelative(record.slice(3)), originalPath: null }
|
|
122
|
+
if (code[0] === 'R' || code[0] === 'C') {
|
|
123
|
+
item.originalPath = normalizeRelative(records[index + 1] || '')
|
|
124
|
+
index += 1
|
|
125
|
+
}
|
|
126
|
+
entries.push(item)
|
|
127
|
+
}
|
|
128
|
+
return entries.sort((left, right) => left.path.localeCompare(right.path, 'en'))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function branchState(gitExecutable, root, failures) {
|
|
132
|
+
const branch = optionalText(gitExecutable, root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { failures, label: 'current branch', allowMissing: true })
|
|
133
|
+
const head = optionalText(gitExecutable, root, ['rev-parse', '--verify', 'HEAD'], { failures, label: 'HEAD identity' })
|
|
134
|
+
const upstream = optionalText(gitExecutable, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { failures, label: 'upstream identity', allowMissing: true })
|
|
135
|
+
let ahead = 0
|
|
136
|
+
let behind = 0
|
|
137
|
+
if (upstream) {
|
|
138
|
+
const counts = optionalText(gitExecutable, root, ['rev-list', '--left-right', '--count', `HEAD...${upstream}`], { failures, label: 'upstream divergence counts' })
|
|
139
|
+
const match = counts?.match(/^(\d+)\s+(\d+)$/)
|
|
140
|
+
if (match) {
|
|
141
|
+
ahead = Number(match[1])
|
|
142
|
+
behind = Number(match[2])
|
|
143
|
+
} else if (counts != null) {
|
|
144
|
+
failures.push(blocker('observation-evidence-invalid', 'required Git evidence is malformed: upstream divergence counts', { probe: 'upstream divergence counts' }))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { branch, detached: !branch, head, upstream, ahead, behind }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function remoteState(gitExecutable, root, failures) {
|
|
151
|
+
const names = (optionalText(gitExecutable, root, ['remote'], { failures, label: 'remote names' }) || '').split(/\r?\n/).filter(Boolean).sort()
|
|
152
|
+
return names.map((name) => {
|
|
153
|
+
const url = optionalText(gitExecutable, root, ['remote', 'get-url', name], { failures, label: `remote URL for ${name}` })
|
|
154
|
+
const pushResult = runGit(gitExecutable, root, ['remote', 'get-url', '--push', '--all', name], { allowFailure: true })
|
|
155
|
+
if (!pushResult.ok) failures.push(blocker('observation-evidence-unavailable', `required Git evidence is unavailable: push destinations for ${name}`, { probe: `push destinations for ${name}` }))
|
|
156
|
+
const pushUrls = pushResult.ok ? pushResult.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean) : []
|
|
157
|
+
const pushUrl = pushUrls.length === 1 ? pushUrls[0] : null
|
|
158
|
+
return {
|
|
159
|
+
name,
|
|
160
|
+
url: sanitizeRemoteUrl(url),
|
|
161
|
+
identityDigest: sha256(sanitizeRemoteUrl(url)),
|
|
162
|
+
authentication: classifyRemoteAuthentication(url),
|
|
163
|
+
pushUrl: pushUrl == null ? null : sanitizeRemoteUrl(pushUrl),
|
|
164
|
+
pushIdentityDigest: pushUrl == null ? null : sha256(sanitizeRemoteUrl(pushUrl)),
|
|
165
|
+
pushAuthentication: pushUrl == null ? 'unknown' : classifyRemoteAuthentication(pushUrl),
|
|
166
|
+
pushTargetCount: pushUrls.length,
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function submoduleState(gitExecutable, root) {
|
|
172
|
+
if (!fs.existsSync(path.join(root, '.gitmodules'))) {
|
|
173
|
+
const staged = runGit(gitExecutable, root, ['ls-files', '--stage', '-z'], { allowFailure: true })
|
|
174
|
+
const gitlinks = staged.ok
|
|
175
|
+
? staged.stdout.split('\0').filter(Boolean).flatMap((record) => {
|
|
176
|
+
const match = record.match(/^160000 [0-9a-f]+ \d+\t(.+)$/u)
|
|
177
|
+
return match ? [{ marker: '?', value: match[1] }] : []
|
|
178
|
+
})
|
|
179
|
+
: []
|
|
180
|
+
if (gitlinks.length) {
|
|
181
|
+
return {
|
|
182
|
+
declared: true,
|
|
183
|
+
entries: gitlinks,
|
|
184
|
+
complete: false,
|
|
185
|
+
error: 'gitlink entries exist without .gitmodules',
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { declared: false, entries: [], complete: staged.ok, error: staged.ok ? null : 'gitlink inventory failed' }
|
|
189
|
+
}
|
|
190
|
+
const result = runGit(gitExecutable, root, ['submodule', 'status', '--recursive'], { allowFailure: true })
|
|
191
|
+
const entries = result.stdout.split(/\r?\n/).filter(Boolean).map((line) => ({
|
|
192
|
+
marker: line[0],
|
|
193
|
+
value: line.slice(1).trim(),
|
|
194
|
+
}))
|
|
195
|
+
const complete = result.ok && entries.every((entry) => entry.marker === ' ')
|
|
196
|
+
return { declared: true, entries, complete, error: result.ok ? null : firstLine(result.stderr) || 'submodule status failed' }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function lfsState(gitExecutable, root, config, gitDir, statusEntries, failures) {
|
|
200
|
+
const trackedAttributes = (optionalText(gitExecutable, root, ['ls-files', '--', '**/.gitattributes', '.gitattributes'], { failures, label: 'tracked attributes' }) || '')
|
|
201
|
+
.split(/\r?\n/)
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
const attributeFiles = trackedAttributes.filter((file) => fs.existsSync(path.join(root, file))).map((file) => ({ label: file, absolute: path.join(root, file) }))
|
|
204
|
+
for (const entry of statusEntries) {
|
|
205
|
+
if (path.basename(entry.path) !== '.gitattributes') continue
|
|
206
|
+
const absolute = path.resolve(root, entry.path)
|
|
207
|
+
const relative = path.relative(root, absolute)
|
|
208
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
209
|
+
failures.push(blocker('observation-evidence-unavailable', 'worktree attributes evidence escapes the repository root'))
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (fs.existsSync(absolute)) attributeFiles.push({ label: entry.path, absolute })
|
|
213
|
+
}
|
|
214
|
+
const infoAttributes = gitDir ? path.join(gitDir, 'info', 'attributes') : null
|
|
215
|
+
if (infoAttributes && fs.existsSync(infoAttributes)) attributeFiles.push({ label: '.git/info/attributes', absolute: infoAttributes })
|
|
216
|
+
for (const [variable, label] of [['GIT_ATTR_GLOBAL', 'global Git attributes'], ['GIT_ATTR_SYSTEM', 'system Git attributes']]) {
|
|
217
|
+
const absolute = optionalText(gitExecutable, root, ['var', variable], { failures, label: `${label} location` })
|
|
218
|
+
if (absolute && fs.existsSync(absolute)) attributeFiles.push({ label, absolute })
|
|
219
|
+
}
|
|
220
|
+
const externalAttributes = config.find((entry) => entry.key.toLowerCase() === 'core.attributesfile')
|
|
221
|
+
if (externalAttributes) {
|
|
222
|
+
failures.push(blocker('external-attributes-file-unclassified', 'core.attributesFile can change Git filter semantics and must be removed or classified', { key: externalAttributes.key }))
|
|
223
|
+
}
|
|
224
|
+
const uniqueAttributeFiles = [...new Map(attributeFiles.map((item) => [item.absolute, item])).values()]
|
|
225
|
+
const required = uniqueAttributeFiles.some(({ label, absolute }) => {
|
|
226
|
+
const stat = fs.lstatSync(absolute)
|
|
227
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
228
|
+
failures.push(blocker('observation-evidence-unavailable', 'Git attributes evidence is redirected or not a regular file', { path: label }))
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return /filter\s*=\s*lfs|filter=lfs/i.test(readRegularTextNoFollow(absolute))
|
|
233
|
+
} catch {
|
|
234
|
+
failures.push(blocker('observation-evidence-unavailable', 'Git attributes evidence changed or could not be read safely', { path: label }))
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
const version = runGit(gitExecutable, root, ['lfs', 'version'], { allowFailure: true })
|
|
239
|
+
const labels = uniqueAttributeFiles.map((item) => item.label)
|
|
240
|
+
if (!required) return { required: false, available: version.ok, complete: !externalAttributes, attributeFiles: labels, error: externalAttributes ? 'external Git attributes are unclassified' : null }
|
|
241
|
+
if (!version.ok) return { required: true, available: false, complete: false, attributeFiles: labels, error: 'Git LFS is required but unavailable' }
|
|
242
|
+
const status = runGit(gitExecutable, root, ['lfs', 'fsck'], { allowFailure: true, timeout: 120_000 })
|
|
243
|
+
return {
|
|
244
|
+
required: true,
|
|
245
|
+
available: true,
|
|
246
|
+
complete: status.ok,
|
|
247
|
+
attributeFiles: labels,
|
|
248
|
+
error: status.ok ? null : firstLine(status.stderr) || 'Git LFS object verification failed',
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function repositoryFeatures(gitExecutable, root, config, failures) {
|
|
253
|
+
const gitDir = optionalText(gitExecutable, root, ['rev-parse', '--absolute-git-dir'], { failures, label: 'absolute Git directory' })
|
|
254
|
+
const commonDirRaw = optionalText(gitExecutable, root, ['rev-parse', '--git-common-dir'], { failures, label: 'Git common directory' })
|
|
255
|
+
const commonDir = commonDirRaw ? path.resolve(root, commonDirRaw) : null
|
|
256
|
+
const worktrees = (optionalText(gitExecutable, root, ['worktree', 'list', '--porcelain'], { failures, label: 'worktree inventory' }) || '')
|
|
257
|
+
.split(/\r?\n/)
|
|
258
|
+
.filter((line) => line.startsWith('worktree '))
|
|
259
|
+
.map((line) => line.slice('worktree '.length))
|
|
260
|
+
// Classify the filter identity before sanitizing its subsection. Once
|
|
261
|
+
// `filter.lfs.clean` becomes `filter.[subsection].clean`, a standard Git LFS
|
|
262
|
+
// installation is indistinguishable from an unreviewed custom filter. CI
|
|
263
|
+
// runners commonly install these keys globally, so the ordering matters.
|
|
264
|
+
const filters = configValues(config, /^filter\./i)
|
|
265
|
+
const customFilters = config
|
|
266
|
+
.filter((entry) => /^filter\./i.test(entry.key) && !/^filter\.lfs\./i.test(entry.key))
|
|
267
|
+
.map((entry) => ({ key: safeConfigKey(entry.key) }))
|
|
268
|
+
const partialClone = configValues(config, /^(?:extensions\.partialclone|remote\..*\.promisor)$/i)
|
|
269
|
+
const urlRewrites = configValues(config, /^url\..*\.(?:insteadof|pushinsteadof)$/i)
|
|
270
|
+
const sparse = boolConfig(config, 'core.sparsecheckout') || Boolean(gitDir && fs.existsSync(path.join(gitDir, 'info', 'sparse-checkout')))
|
|
271
|
+
const shallowRaw = optionalText(gitExecutable, root, ['rev-parse', '--is-shallow-repository'], { failures, label: 'shallow repository state' })
|
|
272
|
+
if (shallowRaw != null && !/^(?:true|false)$/.test(shallowRaw)) {
|
|
273
|
+
failures.push(blocker('observation-evidence-invalid', 'required Git evidence is malformed: shallow repository state', { probe: 'shallow repository state' }))
|
|
274
|
+
}
|
|
275
|
+
const indexFlagsResult = runGit(gitExecutable, root, ['ls-files', '-v', '-z'], { allowFailure: true })
|
|
276
|
+
if (!indexFlagsResult.ok) failures.push(blocker('observation-evidence-unavailable', 'required Git index visibility evidence is unavailable', { probe: 'index visibility flags' }))
|
|
277
|
+
const indexFlags = indexFlagsResult.ok
|
|
278
|
+
? indexFlagsResult.stdout.split('\0').filter(Boolean).reduce((counts, record) => {
|
|
279
|
+
const tag = record[0]
|
|
280
|
+
if (tag === 'S' || tag === 's') counts.skipWorktree += 1
|
|
281
|
+
if (/^[a-z]$/.test(tag)) counts.assumeUnchanged += 1
|
|
282
|
+
return counts
|
|
283
|
+
}, { assumeUnchanged: 0, skipWorktree: 0 })
|
|
284
|
+
: { assumeUnchanged: 0, skipWorktree: 0 }
|
|
285
|
+
return {
|
|
286
|
+
gitDir,
|
|
287
|
+
commonDir,
|
|
288
|
+
linkedWorktree: Boolean(gitDir && commonDir && path.resolve(gitDir) !== path.resolve(commonDir)),
|
|
289
|
+
worktrees,
|
|
290
|
+
sparseCheckout: sparse,
|
|
291
|
+
shallowRepository: shallowRaw === 'true',
|
|
292
|
+
indexFlags,
|
|
293
|
+
partialClone,
|
|
294
|
+
urlRewrites,
|
|
295
|
+
filters,
|
|
296
|
+
customFilters,
|
|
297
|
+
hooksPathConfigured: config.some((entry) => entry.key.toLowerCase() === 'core.hookspath'),
|
|
298
|
+
fileModeTrusted: boolConfig(config, 'core.filemode'),
|
|
299
|
+
symlinksTrusted: !config.some((entry) => entry.key.toLowerCase() === 'core.symlinks' && /^(?:false|no|off|0)$/i.test(entry.value || '')),
|
|
300
|
+
signing: {
|
|
301
|
+
required: boolConfig(config, 'commit.gpgsign'),
|
|
302
|
+
keyConfigured: config.some((entry) => entry.key.toLowerCase() === 'user.signingkey'),
|
|
303
|
+
formatConfigured: config.some((entry) => entry.key.toLowerCase() === 'gpg.format'),
|
|
304
|
+
},
|
|
305
|
+
proxy: configValues(config, /^https?\..*proxy$/i),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function gitMode(stat, index, { fileModeTrusted, symlinksTrusted }) {
|
|
310
|
+
if (stat.isSymbolicLink()) return '120000'
|
|
311
|
+
if (!stat.isFile()) return null
|
|
312
|
+
const indexMode = index?.length === 1 && index[0].stage === 0 ? index[0].mode : null
|
|
313
|
+
if (!symlinksTrusted && indexMode === '120000') return '120000'
|
|
314
|
+
if (!fileModeTrusted && /^100(?:644|755)$/.test(indexMode || '')) return indexMode
|
|
315
|
+
return (stat.mode & 0o111) === 0 ? '100644' : '100755'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function indexEntry(gitExecutable, root, itemPath, failures) {
|
|
319
|
+
const result = runGit(gitExecutable, root, ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', itemPath], { allowFailure: true })
|
|
320
|
+
if (!result.ok) {
|
|
321
|
+
failures.push(blocker('observation-evidence-unavailable', 'required Git index evidence is unavailable', { path: itemPath }))
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
const records = result.stdout.split('\0').filter(Boolean)
|
|
325
|
+
if (!records.length) return null
|
|
326
|
+
const parsed = records.map((record) => {
|
|
327
|
+
const match = record.match(/^(\d{6}) ([0-9a-f]{40,64}) (\d)\t/)
|
|
328
|
+
if (!match) return null
|
|
329
|
+
return { mode: match[1], blob: match[2], stage: Number(match[3]) }
|
|
330
|
+
})
|
|
331
|
+
if (parsed.some((item) => !item)) {
|
|
332
|
+
failures.push(blocker('observation-evidence-invalid', 'required Git index evidence is malformed', { path: itemPath }))
|
|
333
|
+
return null
|
|
334
|
+
}
|
|
335
|
+
return parsed
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function worktreeEntry(gitExecutable, root, itemPath, index, features, failures) {
|
|
339
|
+
const absolute = path.join(root, itemPath)
|
|
340
|
+
let stat
|
|
341
|
+
try {
|
|
342
|
+
stat = fs.lstatSync(absolute)
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error?.code === 'ENOENT') return null
|
|
345
|
+
failures.push(blocker('observation-evidence-unavailable', 'required worktree evidence is unavailable', { path: itemPath }))
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
const mode = gitMode(stat, index, features)
|
|
349
|
+
if (!mode) {
|
|
350
|
+
failures.push(blocker('observation-evidence-invalid', 'changed worktree path is not a regular file or symlink', { path: itemPath }))
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
const symlinkBytes = stat.isSymbolicLink() ? fs.readlinkSync(absolute, { encoding: 'buffer' }) : null
|
|
354
|
+
const result = symlinkBytes
|
|
355
|
+
? runGit(gitExecutable, root, ['hash-object', '--stdin'], { allowFailure: true, input: symlinkBytes })
|
|
356
|
+
: runGit(gitExecutable, root, ['--literal-pathspecs', 'hash-object', '--no-filters', '--', itemPath], { allowFailure: true })
|
|
357
|
+
const blob = result.ok ? result.stdout.trim() : null
|
|
358
|
+
if (!blob || !/^[0-9a-f]{40,64}$/.test(blob)) {
|
|
359
|
+
failures.push(blocker('observation-evidence-unavailable', 'required worktree blob evidence is unavailable', { path: itemPath }))
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
const indexResult = symlinkBytes
|
|
363
|
+
? runGit(gitExecutable, root, ['hash-object', '--stdin'], { allowFailure: true, input: symlinkBytes })
|
|
364
|
+
: runGit(gitExecutable, root, ['--literal-pathspecs', 'hash-object', `--path=${itemPath}`, '--', itemPath], { allowFailure: true })
|
|
365
|
+
const indexBlob = indexResult.ok ? indexResult.stdout.trim() : null
|
|
366
|
+
if (!indexBlob || !/^[0-9a-f]{40,64}$/.test(indexBlob)) {
|
|
367
|
+
failures.push(blocker('observation-evidence-unavailable', 'required filtered blob evidence is unavailable', { path: itemPath }))
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
370
|
+
return { mode, blob, indexBlob }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function statusFingerprints(gitExecutable, root, entries, features, failures) {
|
|
374
|
+
return entries.map((entry) => {
|
|
375
|
+
const index = indexEntry(gitExecutable, root, entry.path, failures)
|
|
376
|
+
const worktree = worktreeEntry(gitExecutable, root, entry.path, index, features, failures)
|
|
377
|
+
const originalIndex = entry.originalPath ? indexEntry(gitExecutable, root, entry.originalPath, failures) : null
|
|
378
|
+
return {
|
|
379
|
+
code: entry.code,
|
|
380
|
+
path: entry.path,
|
|
381
|
+
originalPath: entry.originalPath,
|
|
382
|
+
worktree,
|
|
383
|
+
index,
|
|
384
|
+
originalIndex,
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function blocker(code, message, details = {}) {
|
|
390
|
+
return { code, message, details }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function completenessFor({ filesystem, engine, bare, remotes, features, submodules, lfs, acquisitionFailures }) {
|
|
394
|
+
const blockers = [...acquisitionFailures]
|
|
395
|
+
const warnings = []
|
|
396
|
+
if (!filesystem.supported) blockers.push(blocker(filesystem.code, 'repository root is not on a supported local filesystem'))
|
|
397
|
+
if (!engine.supported) blockers.push(blocker('git-version-unsupported', `Git ${engine.version} is older than ${engine.minimum}`))
|
|
398
|
+
if (bare) blockers.push(blocker('bare-repository-unsupported', 'a working tree is required'))
|
|
399
|
+
if (features.sparseCheckout) blockers.push(blocker('sparse-checkout-unsupported', 'sparse workspaces are not complete observations'))
|
|
400
|
+
if (features.shallowRepository) blockers.push(blocker('shallow-repository-unsupported', 'shallow history is not a complete repository observation'))
|
|
401
|
+
if (features.indexFlags.assumeUnchanged || features.indexFlags.skipWorktree) blockers.push(blocker('index-visibility-flags-unsupported', 'assume-unchanged and skip-worktree index flags can hide authored changes from status', features.indexFlags))
|
|
402
|
+
if (features.partialClone.length) blockers.push(blocker('partial-clone-unsupported', 'partial clones may omit required Git objects', { config: features.partialClone }))
|
|
403
|
+
if (features.urlRewrites.length) blockers.push(blocker('url-rewrite-unclassified', 'Git URL rewrite rules make the execution destination ambiguous and must be removed before synchronization', { config: features.urlRewrites.map((entry) => entry.key) }))
|
|
404
|
+
if (features.hooksPathConfigured) blockers.push(blocker('custom-hooks-path-unclassified', 'core.hooksPath changes executable Git behavior and must be removed or classified before synchronization'))
|
|
405
|
+
if (!submodules.complete) blockers.push(blocker('submodules-incomplete', 'all declared submodules must be initialized and clean', { entries: submodules.entries }))
|
|
406
|
+
if (!lfs.complete) blockers.push(blocker('lfs-incomplete', lfs.error || 'Git LFS content is not complete'))
|
|
407
|
+
if (features.customFilters.length) blockers.push(blocker('custom-filter-unclassified', 'custom Git filters must be classified before synchronization', { filters: features.customFilters.map((entry) => entry.key) }))
|
|
408
|
+
for (const remote of remotes) {
|
|
409
|
+
if (remote.authentication === 'unknown') blockers.push(blocker('remote-authentication-unknown', `${remote.name} uses an unsupported remote URL shape`))
|
|
410
|
+
if (remote.pushTargetCount !== 1) blockers.push(blocker('remote-push-destination-ambiguous', `${remote.name} must resolve to exactly one push destination`, { count: remote.pushTargetCount }))
|
|
411
|
+
else if (remote.pushAuthentication === 'unknown') blockers.push(blocker('remote-push-authentication-unknown', `${remote.name} uses an unsupported push URL shape`))
|
|
412
|
+
}
|
|
413
|
+
if (!remotes.length) warnings.push(blocker('remote-missing', 'repository has no remotes; local commits are possible but synchronization is not'))
|
|
414
|
+
if (features.linkedWorktree) warnings.push(blocker('linked-worktree', 'repository is a linked worktree; the common Git directory is shared'))
|
|
415
|
+
if (features.proxy.length) warnings.push(blocker('git-proxy-configured', 'Git proxy configuration is present and must be preserved'))
|
|
416
|
+
return { complete: blockers.length === 0, blockers, warnings }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function observeRepository({ repoRoot, gitExecutable, observedAt = new Date().toISOString() }) {
|
|
420
|
+
const resolved = resolveRepositoryRoot(repoRoot, gitExecutable)
|
|
421
|
+
if (!resolved.ok) {
|
|
422
|
+
return {
|
|
423
|
+
schema: ATELIER_REPOSITORY_OBSERVATION_SCHEMA,
|
|
424
|
+
observedAt,
|
|
425
|
+
complete: false,
|
|
426
|
+
root: resolved.root,
|
|
427
|
+
filesystem: resolved.filesystem,
|
|
428
|
+
blockers: [blocker(resolved.filesystem?.code || 'repository-unresolved', resolved.error || 'repository could not be resolved')],
|
|
429
|
+
warnings: [],
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const root = resolved.root
|
|
433
|
+
const acquisitionFailures = []
|
|
434
|
+
const engine = inspectGitEngine(gitExecutable)
|
|
435
|
+
const bare = gitText(gitExecutable, root, ['rev-parse', '--is-bare-repository']) === 'true'
|
|
436
|
+
const configResult = runGit(gitExecutable, root, ['config', '--null', '--list'], { allowFailure: true })
|
|
437
|
+
const config = configResult.ok ? parseNullConfig(configResult.stdout) : []
|
|
438
|
+
if (!configResult.ok) acquisitionFailures.push(blocker('observation-evidence-unavailable', 'required Git evidence is unavailable: configuration inventory', { probe: 'configuration inventory' }))
|
|
439
|
+
const branch = branchState(gitExecutable, root, acquisitionFailures)
|
|
440
|
+
const statusResult = runGit(gitExecutable, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
|
|
441
|
+
const allEntries = parsePorcelainStatus(statusResult.stdout)
|
|
442
|
+
const entryCeilingExceeded = allEntries.length > ATELIER_REPOSITORY_OBSERVATION_MAX_ENTRIES
|
|
443
|
+
const entries = allEntries.slice(0, ATELIER_REPOSITORY_OBSERVATION_MAX_ENTRIES)
|
|
444
|
+
if (entryCeilingExceeded) {
|
|
445
|
+
acquisitionFailures.push(blocker('observation-change-set-oversized', 'repository change set exceeds the bounded observation entry ceiling', {
|
|
446
|
+
limitEntries: ATELIER_REPOSITORY_OBSERVATION_MAX_ENTRIES,
|
|
447
|
+
observedEntries: allEntries.length,
|
|
448
|
+
}))
|
|
449
|
+
}
|
|
450
|
+
const remotes = remoteState(gitExecutable, root, acquisitionFailures)
|
|
451
|
+
const features = repositoryFeatures(gitExecutable, root, config, acquisitionFailures)
|
|
452
|
+
const submodules = submoduleState(gitExecutable, root)
|
|
453
|
+
const lfs = lfsState(gitExecutable, root, config, features.gitDir, allEntries, acquisitionFailures)
|
|
454
|
+
const conflicts = allEntries.filter((entry) => entry.code.includes('U') || ['AA', 'DD'].includes(entry.code))
|
|
455
|
+
const staged = allEntries.filter((entry) => entry.code[0] && entry.code[0] !== '?' && entry.code[0] !== ' ')
|
|
456
|
+
const unstaged = allEntries.filter((entry) => entry.code[1] && entry.code[1] !== ' ')
|
|
457
|
+
const fingerprints = entryCeilingExceeded ? [] : statusFingerprints(gitExecutable, root, entries, features, acquisitionFailures)
|
|
458
|
+
const completeness = completenessFor({ filesystem: resolved.filesystem, engine, bare, remotes, features, submodules, lfs, acquisitionFailures })
|
|
459
|
+
const statusDigest = sha256(JSON.stringify({ head: branch.head, branch: branch.branch, rawStatusDigest: sha256(statusResult.stdout), fingerprints }))
|
|
460
|
+
return {
|
|
461
|
+
schema: ATELIER_REPOSITORY_OBSERVATION_SCHEMA,
|
|
462
|
+
observedAt,
|
|
463
|
+
complete: completeness.complete,
|
|
464
|
+
root,
|
|
465
|
+
filesystem: resolved.filesystem,
|
|
466
|
+
git: engine,
|
|
467
|
+
bare,
|
|
468
|
+
branch,
|
|
469
|
+
remotes,
|
|
470
|
+
features,
|
|
471
|
+
submodules,
|
|
472
|
+
lfs,
|
|
473
|
+
status: {
|
|
474
|
+
clean: allEntries.length === 0,
|
|
475
|
+
digest: statusDigest,
|
|
476
|
+
entries,
|
|
477
|
+
fingerprints,
|
|
478
|
+
stagedCount: staged.length,
|
|
479
|
+
unstagedCount: unstaged.length,
|
|
480
|
+
conflictCount: conflicts.length,
|
|
481
|
+
observedEntryCount: allEntries.length,
|
|
482
|
+
entriesTruncated: entryCeilingExceeded,
|
|
483
|
+
},
|
|
484
|
+
blockers: completeness.blockers,
|
|
485
|
+
warnings: completeness.warnings,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function validateRepositoryObservation(doc) {
|
|
490
|
+
return validateJsonSchema(OBSERVATION_CONTRACT, doc)
|
|
491
|
+
}
|