pi-codex-marketplace 0.1.0

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/extensions/pi/git-registration.ts +138 -0
  4. package/extensions/pi/index.ts +293 -0
  5. package/extensions/pi/installation.ts +90 -0
  6. package/extensions/pi/journal.ts +80 -0
  7. package/extensions/pi/lifecycle.ts +285 -0
  8. package/extensions/pi/registration.ts +143 -0
  9. package/extensions/pi/scope-overrides.ts +170 -0
  10. package/package.json +60 -0
  11. package/src/barrier/global-barrier.ts +105 -0
  12. package/src/bridge-state/atomic.ts +237 -0
  13. package/src/bridge-state/index.ts +5 -0
  14. package/src/bridge-state/migrate.ts +261 -0
  15. package/src/bridge-state/paths.ts +75 -0
  16. package/src/bridge-state/repair.ts +185 -0
  17. package/src/bridge-state/schema.ts +70 -0
  18. package/src/bridge-state/store.ts +489 -0
  19. package/src/bridge-state/types.ts +170 -0
  20. package/src/cache/index.ts +2 -0
  21. package/src/cache/paths.ts +42 -0
  22. package/src/cache/source-cache.ts +365 -0
  23. package/src/compatibility/index.ts +1 -0
  24. package/src/compatibility/profile.ts +328 -0
  25. package/src/installation/flow.ts +443 -0
  26. package/src/installation/index.ts +1 -0
  27. package/src/installation/inspection.ts +129 -0
  28. package/src/journal/active-chains.ts +99 -0
  29. package/src/journal/index.ts +3 -0
  30. package/src/journal/journal.ts +215 -0
  31. package/src/journal/types.ts +49 -0
  32. package/src/lifecycle/index.ts +5 -0
  33. package/src/lifecycle/rebind.ts +290 -0
  34. package/src/lifecycle/refresh.ts +407 -0
  35. package/src/lifecycle/removal.ts +457 -0
  36. package/src/lifecycle/update-plan.ts +222 -0
  37. package/src/lifecycle/update.ts +303 -0
  38. package/src/projection/collision.ts +120 -0
  39. package/src/projection/effective-state.ts +182 -0
  40. package/src/projection/index.ts +4 -0
  41. package/src/projection/overrides.ts +230 -0
  42. package/src/projection/project.ts +359 -0
  43. package/src/reconciliation/startup.ts +144 -0
  44. package/src/registration/budget.ts +28 -0
  45. package/src/registration/catalog.ts +224 -0
  46. package/src/registration/contained.ts +140 -0
  47. package/src/registration/fence.ts +86 -0
  48. package/src/registration/findings.ts +188 -0
  49. package/src/registration/flow.ts +619 -0
  50. package/src/registration/git-acquisition.ts +481 -0
  51. package/src/registration/git-flow.ts +654 -0
  52. package/src/registration/git-locator.ts +380 -0
  53. package/src/registration/git-selector.ts +279 -0
  54. package/src/registration/index.ts +16 -0
  55. package/src/registration/receipt.ts +305 -0
  56. package/src/registration/registration.ts +102 -0
  57. package/src/registration/snapshot.ts +382 -0
  58. package/src/registration/source-key.ts +111 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Canonical Git Locator — credential-free HTTPS or SSH repository locator.
3
+ * See CONTEXT.md: Canonical Git Locator.
4
+ *
5
+ * Preserves transport/host/port/path/SSH user.
6
+ * Rejects plaintext/local transports, embedded credentials, query, fragment, ambiguous encoding,
7
+ * whitespace, control characters, backslash.
8
+ */
9
+
10
+ import type { Scope } from '../bridge-state/types.js';
11
+ import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
12
+
13
+ export type GitTransport = 'https' | 'ssh';
14
+
15
+ export interface CanonicalGitLocator {
16
+ /** Original input as provided */
17
+ rawInput: string;
18
+ /** Credential-free canonical URL, e.g. https://github.com/owner/repo or ssh://git@github.com/owner/repo */
19
+ canonicalUrl: string;
20
+ transport: GitTransport;
21
+ host: string;
22
+ port?: number;
23
+ /** Posix path with leading slash, case-sensitive, e.g. /owner/repo.git */
24
+ path: string;
25
+ /** SSH user when transport is ssh, e.g. git */
26
+ user?: string;
27
+ }
28
+
29
+ export interface LocatorResult {
30
+ ok: boolean;
31
+ locator?: CanonicalGitLocator;
32
+ findings: ValidationFinding[];
33
+ }
34
+
35
+ /** Check for control characters (\x00-\x1F, \x7F) */
36
+ function hasControlChars(s: string): boolean {
37
+ // eslint-disable-next-line no-control-regex
38
+ return /[\x00-\x1F\x7F]/.test(s);
39
+ }
40
+
41
+ /** Detect ambiguous percent-encodings that decode to sensitive characters: / \ ? # @ : and NUL */
42
+ function hasAmbiguousEncoding(s: string): { bad: boolean; reason?: string } {
43
+ // Detect lone % not forming valid encoding first (before early return)
44
+ if (/%(?![0-9A-Fa-f]{2})/.test(s)) {
45
+ return { bad: true, reason: 'lone % without two hex digits' };
46
+ }
47
+ const pct = s.match(/%[0-9A-Fa-f]{2}/g);
48
+ if (!pct) return { bad: false };
49
+ const sensitive = new Set(['%2f', '%2F', '%5c', '%5C', '%3f', '%3F', '%23', '%00', '%40', '%3a', '%3A', '%2e', '%2E']);
50
+ for (const enc of pct) {
51
+ const low = enc.toLowerCase();
52
+ if (sensitive.has(low)) {
53
+ return { bad: true, reason: `ambiguous percent-encoding '${enc}' decodes to sensitive character` };
54
+ }
55
+ }
56
+ return { bad: false };
57
+ }
58
+
59
+ const SCP_RE = /^(?:(?<user>[A-Za-z0-9._-]+)@)?(?<host>[A-Za-z0-9.-]+):(?<path>[^\0\s?#]+)$/;
60
+
61
+ function normalizeHost(host: string): string {
62
+ return host.toLowerCase();
63
+ }
64
+
65
+ function normalizePath(path: string): string {
66
+ // Ensure leading slash, collapse duplicate slashes already rejected, keep case, remove trailing slash not needed
67
+ let p = path;
68
+ if (!p.startsWith('/')) p = '/' + p;
69
+ // Remove trailing slash except when path is just "/"
70
+ if (p.length > 1 && p.endsWith('/')) p = p.replace(/\/+$/, '');
71
+ return p;
72
+ }
73
+
74
+ function validateHost(host: string): boolean {
75
+ if (!host || host.length === 0) return false;
76
+ if (host.includes('..')) return false;
77
+ if (!/^[A-Za-z0-9.-]+$/.test(host)) return false;
78
+ if (host.startsWith('.') || host.startsWith('-') || host.endsWith('.') || host.endsWith('-')) return false;
79
+ return true;
80
+ }
81
+
82
+ function validatePath(path: string): { ok: boolean; reason?: string } {
83
+ if (!path || path.length === 0) return { ok: false, reason: 'empty path' };
84
+ if (path.includes('\\')) return { ok: false, reason: 'backslash in path' };
85
+ if (path.includes('//')) return { ok: false, reason: 'double slash in path' };
86
+ if (path.includes('\0')) return { ok: false, reason: 'NUL in path' };
87
+ // Path must be slash-separated segments, each segment non-empty, not "." or "..", no whitespace/control
88
+ const segments = path.split('/').filter((s) => s.length > 0);
89
+ if (segments.length === 0) return { ok: false, reason: 'no path segments' };
90
+ for (const seg of segments) {
91
+ if (seg === '.' || seg === '..') return { ok: false, reason: `dot segment '${seg}'` };
92
+ if (/\s/.test(seg)) return { ok: false, reason: `whitespace in segment '${seg}'` };
93
+ if (/[\x00-\x1F\x7F]/.test(seg)) return { ok: false, reason: `control character in segment '${seg}'` };
94
+ }
95
+ return { ok: true };
96
+ }
97
+
98
+ /** Build a blocking finding for locator */
99
+ function locatorFinding(scope: Scope, code: string, rule: string, outcome: string): ValidationFinding {
100
+ return blocking({
101
+ code,
102
+ phase: 'validation',
103
+ target: 'source',
104
+ scope,
105
+ pointer: '',
106
+ rule,
107
+ outcome,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Normalize a Git Locator to its credential-free canonical form.
113
+ * Preserves transport/host/port/path/SSH user; rejects plaintext, credentials, query/fragment, ambiguous encoding.
114
+ */
115
+ export function normalizeGitLocator(input: string, scope: Scope): LocatorResult {
116
+ const findings: ValidationFinding[] = [];
117
+ const raw = input;
118
+
119
+ if (typeof input !== 'string' || input.length === 0) {
120
+ findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator is empty'));
121
+ return { ok: false, findings };
122
+ }
123
+
124
+ // Control chars
125
+ if (hasControlChars(input)) {
126
+ findings.push(
127
+ locatorFinding(scope, CODE.GIT_LOCATOR_CONTROL_CHARS, RULE.GIT_LOCATOR_CONTROL_CHARS, 'Git locator contains control characters'),
128
+ );
129
+ return { ok: false, findings };
130
+ }
131
+
132
+ // Whitespace (space, tab, newline)
133
+ if (/\s/.test(input)) {
134
+ findings.push(
135
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator contains whitespace'),
136
+ );
137
+ return { ok: false, findings };
138
+ }
139
+
140
+ if (input.includes('\\')) {
141
+ findings.push(
142
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator contains backslash'),
143
+ );
144
+ return { ok: false, findings };
145
+ }
146
+
147
+ // Query / fragment — any ? or # is rejected (not accepted per spec)
148
+ if (input.includes('?') || input.includes('#')) {
149
+ findings.push(
150
+ locatorFinding(
151
+ scope,
152
+ CODE.GIT_LOCATOR_QUERY_FRAGMENT,
153
+ RULE.GIT_LOCATOR_QUERY_FRAGMENT,
154
+ 'Git locator must not contain query (?) or fragment (#)',
155
+ ),
156
+ );
157
+ return { ok: false, findings };
158
+ }
159
+
160
+ // Ambiguous encoding
161
+ const amb = hasAmbiguousEncoding(input);
162
+ if (amb.bad) {
163
+ findings.push(
164
+ locatorFinding(
165
+ scope,
166
+ CODE.GIT_LOCATOR_AMBIGUOUS_ENCODING,
167
+ RULE.GIT_LOCATOR_AMBIGUOUS_ENCODING,
168
+ `Git locator has ambiguous encoding: ${amb.reason}`,
169
+ ),
170
+ );
171
+ return { ok: false, findings };
172
+ }
173
+
174
+ // Detect plaintext/local transports early by checking scheme
175
+ // If input contains "://", parse scheme
176
+ const hasScheme = input.includes('://');
177
+
178
+ // SCP-like handling (no scheme, contains @ and : before slash)
179
+ if (!hasScheme) {
180
+ // Check if it looks like a local path or file:// without scheme
181
+ if (input.startsWith('/') || input.startsWith('.') || input.startsWith('file:')) {
182
+ findings.push(
183
+ locatorFinding(scope, CODE.GIT_LOCATOR_PLAINTEXT, RULE.GIT_LOCATOR_PLAINTEXT, `Git locator transport is not allowed: local/file transport rejected — use https:// or ssh://`),
184
+ );
185
+ return { ok: false, findings };
186
+ }
187
+ const m = input.match(SCP_RE);
188
+ if (m && m.groups) {
189
+ const user = m.groups.user;
190
+ const host = normalizeHost(m.groups.host);
191
+ let path = m.groups.path;
192
+ // path from scp is like owner/repo.git without leading slash; ensure we treat
193
+ if (path.startsWith('/')) {
194
+ findings.push(
195
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'SCP-like path must not start with /'),
196
+ );
197
+ return { ok: false, findings };
198
+ }
199
+ // Check for embedded credentials: path should not contain @ or : beyond the initial separator
200
+ if (path.includes('@')) {
201
+ findings.push(
202
+ locatorFinding(scope, CODE.GIT_LOCATOR_CREDENTIAL, RULE.GIT_LOCATOR_CREDENTIAL, 'SCP-like locator must not contain @ in path (embedded credential)'),
203
+ );
204
+ return { ok: false, findings };
205
+ }
206
+ if (!validateHost(host)) {
207
+ findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid host '${host}'`));
208
+ return { ok: false, findings };
209
+ }
210
+ const normPath = normalizePath(path);
211
+ const pathCheck = validatePath(normPath);
212
+ if (!pathCheck.ok) {
213
+ findings.push(
214
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid path '${path}': ${pathCheck.reason}`),
215
+ );
216
+ return { ok: false, findings };
217
+ }
218
+ const canonicalUrl = user ? `ssh://${user}@${host}${normPath}` : `ssh://${host}${normPath}`;
219
+ return {
220
+ ok: true,
221
+ findings: [],
222
+ locator: {
223
+ rawInput: raw,
224
+ canonicalUrl,
225
+ transport: 'ssh',
226
+ host,
227
+ path: normPath,
228
+ user,
229
+ },
230
+ };
231
+ }
232
+ // No scheme and not SCP => invalid
233
+ findings.push(
234
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator must be https://, ssh://, or scp-like user@host:path — missing scheme'),
235
+ );
236
+ return { ok: false, findings };
237
+ }
238
+
239
+ // Has scheme: parse via URL
240
+ let url: URL;
241
+ try {
242
+ url = new URL(input);
243
+ } catch (e) {
244
+ const msg = e instanceof Error ? e.message : String(e);
245
+ findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `unable to parse locator: ${msg}`));
246
+ return { ok: false, findings };
247
+ }
248
+
249
+ const scheme = url.protocol; // includes colon, e.g. "https:"
250
+ if (scheme === 'http:') {
251
+ findings.push(
252
+ locatorFinding(
253
+ scope,
254
+ CODE.GIT_LOCATOR_PLAINTEXT,
255
+ RULE.GIT_LOCATOR_PLAINTEXT,
256
+ 'plaintext http:// transport is not allowed — use https://',
257
+ ),
258
+ );
259
+ return { ok: false, findings };
260
+ }
261
+ if (scheme === 'ftp:' || scheme === 'file:' || scheme === 'git:') {
262
+ findings.push(
263
+ locatorFinding(scope, CODE.GIT_LOCATOR_PLAINTEXT, RULE.GIT_LOCATOR_PLAINTEXT, `transport '${scheme.slice(0, -1)}' is not allowed — use https:// or ssh://`),
264
+ );
265
+ return { ok: false, findings };
266
+ }
267
+ if (scheme !== 'https:' && scheme !== 'ssh:') {
268
+ findings.push(
269
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `unsupported transport '${scheme.slice(0, -1)}' — use https:// or ssh://`),
270
+ );
271
+ return { ok: false, findings };
272
+ }
273
+
274
+ // Credentials check
275
+ if (scheme === 'https:' && (url.username.length > 0 || url.password.length > 0)) {
276
+ findings.push(
277
+ locatorFinding(
278
+ scope,
279
+ CODE.GIT_LOCATOR_CREDENTIAL,
280
+ RULE.GIT_LOCATOR_CREDENTIAL,
281
+ 'https locator must not contain embedded credentials (user:pass@)',
282
+ ),
283
+ );
284
+ return { ok: false, findings };
285
+ }
286
+ if (scheme === 'ssh:' && url.password.length > 0) {
287
+ findings.push(
288
+ locatorFinding(
289
+ scope,
290
+ CODE.GIT_LOCATOR_CREDENTIAL,
291
+ RULE.GIT_LOCATOR_CREDENTIAL,
292
+ 'ssh locator must not contain password (user:pass@)',
293
+ ),
294
+ );
295
+ return { ok: false, findings };
296
+ }
297
+
298
+ // Query/fragment already rejected, but double-check URL search/hash
299
+ if (url.search.length > 0 || url.hash.length > 0) {
300
+ findings.push(
301
+ locatorFinding(
302
+ scope,
303
+ CODE.GIT_LOCATOR_QUERY_FRAGMENT,
304
+ RULE.GIT_LOCATOR_QUERY_FRAGMENT,
305
+ 'Git locator must not contain query or fragment',
306
+ ),
307
+ );
308
+ return { ok: false, findings };
309
+ }
310
+
311
+ const host = normalizeHost(url.hostname);
312
+ if (!validateHost(host)) {
313
+ findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid host '${host}'`));
314
+ return { ok: false, findings };
315
+ }
316
+
317
+ // Port handling
318
+ let port: number | undefined;
319
+ if (url.port) {
320
+ const p = Number(url.port);
321
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) {
322
+ findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid port '${url.port}'`));
323
+ return { ok: false, findings };
324
+ }
325
+ port = p;
326
+ // For https, default 443 may be omitted in canonical? Keep only if non-default for simplicity preserve as given
327
+ // For ssh, default 22 omitted
328
+ if ((scheme === 'https:' && port === 443) || (scheme === 'ssh:' && port === 22)) {
329
+ port = undefined; // omit default
330
+ }
331
+ }
332
+
333
+ const rawPath = url.pathname; // includes leading slash
334
+ // For ssh/https, pathname must be at least /something
335
+ if (!rawPath || rawPath === '/') {
336
+ findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator path is empty — expected /owner/repo'));
337
+ return { ok: false, findings };
338
+ }
339
+ const normPath = normalizePath(rawPath);
340
+ const pathCheck = validatePath(normPath);
341
+ if (!pathCheck.ok) {
342
+ findings.push(
343
+ locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid path '${rawPath}': ${pathCheck.reason}`),
344
+ );
345
+ return { ok: false, findings };
346
+ }
347
+
348
+ // Check ambiguous encoding in path already done via raw input, but also check pathname
349
+ // User part: for ssh, preserve
350
+ const user = url.username || undefined;
351
+
352
+ // Build canonical URL
353
+ let canonicalUrl: string;
354
+ const transport: GitTransport = scheme === 'https:' ? 'https' : 'ssh';
355
+ if (transport === 'https') {
356
+ canonicalUrl = `https://${host}${port ? `:${port}` : ''}${normPath}`;
357
+ } else {
358
+ canonicalUrl = `ssh://${user ? `${user}@` : ''}${host}${port ? `:${port}` : ''}${normPath}`;
359
+ }
360
+
361
+ return {
362
+ ok: true,
363
+ findings: [],
364
+ locator: {
365
+ rawInput: raw,
366
+ canonicalUrl,
367
+ transport,
368
+ host,
369
+ port,
370
+ path: normPath,
371
+ user,
372
+ },
373
+ };
374
+ }
375
+
376
+ /** Check if a locator string is canonical (already normalized). */
377
+ export function isCanonicalGitLocator(canonical: string, scope: Scope): boolean {
378
+ const res = normalizeGitLocator(canonical, scope);
379
+ return res.ok && res.locator!.canonicalUrl === canonical;
380
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Git Selector — structured default/branch/tag/commit choice.
3
+ * See CONTEXT.md: Git Selector.
4
+ *
5
+ * Normalization:
6
+ * - default → "default"
7
+ * - branch → "refs/heads/<name>" (case-sensitive)
8
+ * - tag → "refs/tags/<name>"
9
+ * - commit → lowercase complete 40 or 64 hex
10
+ *
11
+ * Rejects ambiguous shorthand, abbreviated object names, generic refs, HEAD, revision/reflog expressions,
12
+ * option-like values, whitespace, control characters.
13
+ */
14
+
15
+ import type { Scope } from '../bridge-state/types.js';
16
+ import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
17
+
18
+ export type GitSelectorKind = 'default' | 'branch' | 'tag' | 'commit';
19
+
20
+ export interface GitSelectorInput {
21
+ kind: GitSelectorKind;
22
+ value?: string;
23
+ }
24
+
25
+ export interface NormalizedGitSelector {
26
+ kind: GitSelectorKind;
27
+ /** Canonical form: 'default' | 'refs/heads/<name>' | 'refs/tags/<name>' | lower 40/64 hex */
28
+ canonical: string;
29
+ /** Original raw value before canonicalization (for display) */
30
+ raw?: string;
31
+ }
32
+
33
+ export interface SelectorResult {
34
+ ok: boolean;
35
+ selector?: NormalizedGitSelector;
36
+ findings: ValidationFinding[];
37
+ }
38
+
39
+ function hasControlOrWhitespace(s: string): { has: boolean; reason?: string } {
40
+ // eslint-disable-next-line no-control-regex
41
+ if (/[\x00-\x1F\x7F]/.test(s)) return { has: true, reason: 'control character' };
42
+ if (/\s/.test(s)) return { has: true, reason: 'whitespace' };
43
+ return { has: false };
44
+ }
45
+
46
+ function isOptionLike(s: string): boolean {
47
+ return s.startsWith('-');
48
+ }
49
+
50
+ function isHead(s: string): boolean {
51
+ return s === 'HEAD' || s === 'head';
52
+ }
53
+
54
+ function containsReflog(s: string): boolean {
55
+ return s.includes('@{');
56
+ }
57
+
58
+ function containsRevisionChars(s: string): boolean {
59
+ return /[~^:]/.test(s);
60
+ }
61
+
62
+ function isHex(s: string): boolean {
63
+ return /^[0-9a-fA-F]+$/.test(s);
64
+ }
65
+
66
+ /** Git ref-name validation per git-check-ref-format (simplified, closing). */
67
+ function isValidRefName(name: string): { ok: boolean; reason?: string } {
68
+ if (name.length === 0) return { ok: false, reason: 'empty' };
69
+ if (hasControlOrWhitespace(name).has) return { ok: false, reason: hasControlOrWhitespace(name).reason };
70
+ if (name.includes('\\')) return { ok: false, reason: 'backslash' };
71
+ if (name.includes('..')) return { ok: false, reason: 'double dot ..' };
72
+ if (name.includes('//')) return { ok: false, reason: 'double slash //' };
73
+ if (name.includes('@{')) return { ok: false, reason: 'reflog @{ ' };
74
+ if (/[~^:?*\[\\]/.test(name)) return { ok: false, reason: 'invalid character ~ ^ : ? * [ \\' };
75
+ if (name.startsWith('.') || name.startsWith('/')) return { ok: false, reason: 'starts with . or /' };
76
+ if (name.endsWith('.') || name.endsWith('/') || name.endsWith('.lock')) return { ok: false, reason: 'ends with . / or .lock' };
77
+ // Each component between slashes must not start with . and not be empty
78
+ const parts = name.split('/');
79
+ for (const p of parts) {
80
+ if (p.length === 0) return { ok: false, reason: 'empty component' };
81
+ if (p.startsWith('.')) return { ok: false, reason: `component '${p}' starts with .` };
82
+ if (p === '@') return { ok: false, reason: 'single @' };
83
+ if (isHead(p)) {
84
+ // HEAD as component is not allowed in branch/tag? Spec says HEAD is rejected overall.
85
+ // But branch name "HEAD" itself is invalid; path component equal HEAD is also questionable.
86
+ // For git ref rules, HEAD is special; we reject it.
87
+ return { ok: false, reason: 'HEAD' };
88
+ }
89
+ }
90
+ return { ok: true };
91
+ }
92
+
93
+ function selectorFinding(scope: Scope, code: string, rule: string, outcome: string): ValidationFinding {
94
+ return blocking({
95
+ code,
96
+ phase: 'validation',
97
+ target: 'source',
98
+ scope,
99
+ pointer: '',
100
+ rule,
101
+ outcome,
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Normalize a Git Selector.
107
+ * Input is a structured { kind, value } where branch/tag value is the short name (or possibly already qualified).
108
+ */
109
+ export function normalizeGitSelector(input: GitSelectorInput, scope: Scope): SelectorResult {
110
+ const findings: ValidationFinding[] = [];
111
+ const kind = input.kind;
112
+ const rawValue = input.value ?? '';
113
+
114
+ // Common whitespace/control rejection on kind
115
+ if (typeof kind !== 'string' || kind.length === 0) {
116
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, 'Git selector kind is missing'));
117
+ return { ok: false, findings };
118
+ }
119
+
120
+ // Normalize kind string
121
+ const k = String(kind).toLowerCase() as GitSelectorKind;
122
+ if (k !== 'default' && k !== 'branch' && k !== 'tag' && k !== 'commit') {
123
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `unknown Git selector kind '${kind}' — expected default/branch/tag/commit`));
124
+ return { ok: false, findings };
125
+ }
126
+
127
+ // default selector: value must be empty/undefined and canonical is "default"
128
+ if (k === 'default') {
129
+ if (rawValue !== undefined && String(rawValue).trim().length > 0) {
130
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, 'default selector must not have a value'));
131
+ return { ok: false, findings };
132
+ }
133
+ return { ok: true, findings: [], selector: { kind: 'default', canonical: 'default', raw: 'default' } };
134
+ }
135
+
136
+ // For branch/tag/commit, value is required
137
+ const value = String(rawValue ?? '');
138
+ if (value.length === 0) {
139
+ const code = k === 'branch' ? CODE.GIT_SELECTOR_BRANCH_INVALID : k === 'tag' ? CODE.GIT_SELECTOR_TAG_INVALID : CODE.GIT_SELECTOR_COMMIT_INVALID;
140
+ const rule = k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : k === 'tag' ? RULE.GIT_SELECTOR_TAG_INVALID : RULE.GIT_SELECTOR_COMMIT_INVALID;
141
+ findings.push(selectorFinding(scope, code, rule, `${k} selector value is empty`));
142
+ return { ok: false, findings };
143
+ }
144
+
145
+ const ws = hasControlOrWhitespace(value);
146
+ if (ws.has) {
147
+ const code = k === 'commit' ? CODE.GIT_SELECTOR_COMMIT_INVALID : CODE.GIT_SELECTOR_INVALID;
148
+ const rule = k === 'commit' ? RULE.GIT_SELECTOR_COMMIT_INVALID : RULE.GIT_SELECTOR_INVALID;
149
+ findings.push(selectorFinding(scope, code, rule, `${k} selector contains ${ws.reason}`));
150
+ return { ok: false, findings };
151
+ }
152
+ if (isOptionLike(value)) {
153
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not be option-like '${value}'`));
154
+ return { ok: false, findings };
155
+ }
156
+ if (isHead(value) || isHead(value.split('/').pop() ?? '')) {
157
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not be HEAD`));
158
+ return { ok: false, findings };
159
+ }
160
+ if (containsReflog(value)) {
161
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not contain reflog '@{'`));
162
+ return { ok: false, findings };
163
+ }
164
+ if (containsRevisionChars(value)) {
165
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not contain revision characters ~ ^ :`));
166
+ return { ok: false, findings };
167
+ }
168
+
169
+ if (k === 'branch' || k === 'tag') {
170
+ // Reject commit-like hex as branch/tag? Spec separates branch/tag vs commit; but branch named 40 hex is technically ambiguous.
171
+ // We will reject branch/tag values that are exactly 40/64 hex to avoid confusion with commit shorthand rejection?
172
+ // But per spec, branch/tag obey ref-name rules; a 40 hex string could be a valid ref name, but spec says commit is 40/64 hex.
173
+ // For determinism, we allow hex as branch/tag if it passes ref validation? However tests may expect branch 'abc' hex handling.
174
+ // We'll not reject hex for branch/tag here; commit is separate.
175
+
176
+ // If value already looks like a fully qualified ref, handle
177
+ const expectedPrefix = k === 'branch' ? 'refs/heads/' : 'refs/tags/';
178
+ let name = value;
179
+ let hadPrefix = false;
180
+ if (value.startsWith('refs/')) {
181
+ if (!value.startsWith(expectedPrefix)) {
182
+ findings.push(
183
+ selectorFinding(
184
+ scope,
185
+ CODE.GIT_SELECTOR_INVALID,
186
+ k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID,
187
+ `${k} selector with refs/ prefix must be ${expectedPrefix}<name>, got '${value}'`,
188
+ ),
189
+ );
190
+ return { ok: false, findings };
191
+ }
192
+ hadPrefix = true;
193
+ name = value.slice(expectedPrefix.length);
194
+ if (name.length === 0) {
195
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID, `${k} selector ref name is empty after prefix`));
196
+ return { ok: false, findings };
197
+ }
198
+ }
199
+
200
+ // Validate the short name per ref rules
201
+ const refCheck = isValidRefName(name);
202
+ if (!refCheck.ok) {
203
+ const code = k === 'branch' ? CODE.GIT_SELECTOR_BRANCH_INVALID : CODE.GIT_SELECTOR_TAG_INVALID;
204
+ const rule = k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID;
205
+ findings.push(selectorFinding(scope, code, rule, `${k} selector ref name invalid '${name}': ${refCheck.reason}`));
206
+ return { ok: false, findings };
207
+ }
208
+
209
+ // Also validate full canonical ref
210
+ const canonical = `${expectedPrefix}${name}`;
211
+ const fullCheck = isValidRefName(canonical);
212
+ if (!fullCheck.ok) {
213
+ const code = k === 'branch' ? CODE.GIT_SELECTOR_BRANCH_INVALID : CODE.GIT_SELECTOR_TAG_INVALID;
214
+ const rule = k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID;
215
+ findings.push(selectorFinding(scope, code, rule, `canonical ${k} ref '${canonical}' invalid: ${fullCheck.reason}`));
216
+ return { ok: false, findings };
217
+ }
218
+
219
+ return { ok: true, findings: [], selector: { kind: k, canonical, raw: value } };
220
+ }
221
+
222
+ // commit
223
+ if (k === 'commit') {
224
+ // Must be complete 40 or 64 hex, lowercased
225
+ // Reject abbreviations, HEAD, etc already handled
226
+ if (value.length !== 40 && value.length !== 64) {
227
+ findings.push(
228
+ selectorFinding(
229
+ scope,
230
+ CODE.GIT_SELECTOR_COMMIT_INVALID,
231
+ RULE.GIT_SELECTOR_COMMIT_INVALID,
232
+ `commit selector must be complete 40 or 64 hex (got ${value.length} chars) — abbreviated object names not accepted`,
233
+ ),
234
+ );
235
+ return { ok: false, findings };
236
+ }
237
+ if (!isHex(value)) {
238
+ findings.push(
239
+ selectorFinding(scope, CODE.GIT_SELECTOR_COMMIT_INVALID, RULE.GIT_SELECTOR_COMMIT_INVALID, `commit selector must be hex, got '${value}'`),
240
+ );
241
+ return { ok: false, findings };
242
+ }
243
+ // Canonical is lowercase
244
+ const canonical = value.toLowerCase();
245
+ // Validate that lowercasing didn't change length etc (already)
246
+ return { ok: true, findings: [], selector: { kind: 'commit', canonical, raw: value } };
247
+ }
248
+
249
+ // Fallback
250
+ findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `unhandled selector kind '${k}'`));
251
+ return { ok: false, findings };
252
+ }
253
+
254
+ /** Parse a freeform string like "default", "branch:main", "refs/heads/main", "abc...40hex" into a structured selector */
255
+ export function parseGitSelectorString(input: string, scope: Scope): SelectorResult {
256
+ const s = String(input ?? '').trim();
257
+ if (s.length === 0) {
258
+ return { ok: false, findings: [selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, 'empty selector string')] };
259
+ }
260
+ if (s === 'default') return normalizeGitSelector({ kind: 'default' }, scope);
261
+ if (s.startsWith('refs/heads/')) return normalizeGitSelector({ kind: 'branch', value: s }, scope);
262
+ if (s.startsWith('refs/tags/')) return normalizeGitSelector({ kind: 'tag', value: s }, scope);
263
+ if (/^[0-9a-fA-F]{40}$/.test(s) || /^[0-9a-fA-F]{64}$/.test(s)) return normalizeGitSelector({ kind: 'commit', value: s }, scope);
264
+ // branch:xxx or tag:xxx or commit:xxx syntax
265
+ const colon = s.indexOf(':');
266
+ if (colon > 0) {
267
+ const kind = s.slice(0, colon).toLowerCase();
268
+ const val = s.slice(colon + 1);
269
+ if ((kind === 'branch' || kind === 'tag' || kind === 'commit') && val) {
270
+ return normalizeGitSelector({ kind: kind as GitSelectorKind, value: val }, scope);
271
+ }
272
+ }
273
+ // Default to branch short name? But we can't disambiguate. For now, if it looks like a branch name, treat as branch
274
+ // Safer to return invalid to force explicit kind.
275
+ return {
276
+ ok: false,
277
+ findings: [selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `unable to parse selector string '${s}' — expected default, refs/heads/*, refs/tags/*, 40/64 hex, or kind:value`)],
278
+ };
279
+ }
@@ -0,0 +1,16 @@
1
+ export * from './findings.js';
2
+ export * from './source-key.js';
3
+ export * from './catalog.js';
4
+ export * from './contained.js';
5
+ export * from './budget.js';
6
+ export * from './snapshot.js';
7
+ export * from './registration.js';
8
+ export * from './fence.js';
9
+ export * from './receipt.js';
10
+ export * from './flow.js';
11
+ export * from './git-locator.js';
12
+ export * from './git-selector.js';
13
+ export * from './git-acquisition.js';
14
+ export * from './git-flow.js';
15
+ export * from '../compatibility/index.js';
16
+ export * from '../installation/index.js';