@guilz-dev/belay 0.9.2 → 0.9.4

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 (118) hide show
  1. package/README.md +36 -2
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  5. package/dist/adapters/codex/runtime-entry.js +27 -4
  6. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  7. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  8. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  9. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  10. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +7 -0
  11. package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
  12. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  13. package/dist/adapters/cursor/hook-router.js +266 -0
  14. package/dist/adapters/cursor/hooks.d.ts +5 -0
  15. package/dist/adapters/cursor/hooks.js +115 -3
  16. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  17. package/dist/adapters/cursor/routing-layout.js +29 -0
  18. package/dist/adapters/cursor/runtime-entry.d.ts +18 -0
  19. package/dist/adapters/cursor/runtime-entry.js +135 -34
  20. package/dist/adapters/layouts/scope.d.ts +5 -0
  21. package/dist/adapters/layouts/scope.js +71 -0
  22. package/dist/adapters/shared/gate-runtime.js +27 -3
  23. package/dist/adapters/shared/repo-root.js +20 -1
  24. package/dist/bundle/claude-runtime.mjs +2660 -1951
  25. package/dist/bundle/codex-runtime.mjs +2680 -1953
  26. package/dist/bundle/cursor-dispatcher.mjs +443 -0
  27. package/dist/bundle/cursor-runtime.mjs +5638 -4697
  28. package/dist/cli.js +33 -3
  29. package/dist/commands/doctor.js +215 -16
  30. package/dist/commands/health-snapshot.d.ts +3 -0
  31. package/dist/commands/health-snapshot.js +61 -0
  32. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  33. package/dist/commands/report.js +14 -0
  34. package/dist/commands/status.js +15 -0
  35. package/dist/commands/where.d.ts +4 -0
  36. package/dist/commands/where.js +52 -0
  37. package/dist/config-io.js +11 -2
  38. package/dist/core/approval-repo-lookup.d.ts +16 -0
  39. package/dist/core/approval-repo-lookup.js +48 -0
  40. package/dist/core/audit-io.d.ts +1 -1
  41. package/dist/core/audit-io.js +1 -1
  42. package/dist/core/audit-legacy-archive.js +43 -25
  43. package/dist/core/audit-query.d.ts +1 -0
  44. package/dist/core/audit-query.js +7 -0
  45. package/dist/core/audit-serialize.d.ts +3 -0
  46. package/dist/core/audit-serialize.js +39 -3
  47. package/dist/core/audit-summary.d.ts +9 -0
  48. package/dist/core/audit-summary.js +58 -1
  49. package/dist/core/audit-types.d.ts +4 -0
  50. package/dist/core/classify-tool.js +16 -8
  51. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  52. package/dist/core/effect-ir/argv-delegate.js +42 -0
  53. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  54. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  55. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  56. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  57. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  58. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  59. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  60. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  61. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  62. package/dist/core/effect-ir/shell-lower/decoders/belay.js +29 -0
  63. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  64. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  65. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  66. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  67. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  68. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  69. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  70. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  71. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  72. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  73. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  74. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  75. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  76. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  77. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  78. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  79. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  80. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  81. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  82. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  83. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  84. package/dist/core/effect-ir/shell-lower.js +122 -1511
  85. package/dist/core/glob.js +32 -16
  86. package/dist/core/integrity.d.ts +2 -2
  87. package/dist/core/integrity.js +52 -12
  88. package/dist/core/replay-scrub.d.ts +3 -0
  89. package/dist/core/replay-scrub.js +30 -3
  90. package/dist/core/shell-substitution.js +2 -2
  91. package/dist/core/shell-tokenizer.d.ts +28 -0
  92. package/dist/core/shell-tokenizer.js +111 -29
  93. package/dist/core/shell-unparseable.js +3 -1
  94. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  95. package/dist/core/verdict/docker-compose-run.js +136 -0
  96. package/dist/core/verdict/launcher-resolve.js +28 -28
  97. package/dist/core/verdict/parser.d.ts +3 -0
  98. package/dist/core/verdict/parser.js +23 -40
  99. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  100. package/dist/core/verdict/recursive-invocation.js +224 -0
  101. package/dist/defaults.d.ts +7 -3
  102. package/dist/defaults.js +35 -21
  103. package/dist/installer/bootstrap.d.ts +1 -0
  104. package/dist/installer/bootstrap.js +2 -2
  105. package/dist/installer/runtime-artifacts.js +43 -13
  106. package/dist/installer/scope-config.d.ts +2 -2
  107. package/dist/installer.d.ts +10 -1
  108. package/dist/installer.js +83 -7
  109. package/dist/node-resolution.d.ts +1 -0
  110. package/dist/node-resolution.js +61 -0
  111. package/dist/templates.d.ts +7 -4
  112. package/dist/templates.js +42 -4
  113. package/dist/types.d.ts +35 -1
  114. package/dist/version.d.ts +1 -1
  115. package/dist/version.js +1 -1
  116. package/package.json +5 -2
  117. package/skills/belay/SKILL.md +5 -0
  118. package/skills/belay/belay-report.md +4 -1
package/dist/core/glob.js CHANGED
@@ -1,26 +1,42 @@
1
+ import { escapeRegex } from './approval.js';
2
+ function globPatternToRegexSource(pattern) {
3
+ let result = '';
4
+ let index = 0;
5
+ while (index < pattern.length) {
6
+ const char = pattern[index];
7
+ if (char === '*' && pattern[index + 1] === '*') {
8
+ index += 2;
9
+ if (pattern[index] === '/') {
10
+ result += '(?:.*/)?';
11
+ index += 1;
12
+ }
13
+ else {
14
+ result += '.*';
15
+ }
16
+ continue;
17
+ }
18
+ if (char === '*') {
19
+ result += '[^/]*';
20
+ index += 1;
21
+ continue;
22
+ }
23
+ result += escapeRegex(char);
24
+ index += 1;
25
+ }
26
+ return result;
27
+ }
28
+ function globPatternMatches(pattern, normalized, baseName) {
29
+ const regex = new RegExp(`^${globPatternToRegexSource(pattern)}$`);
30
+ return regex.test(normalized) || regex.test(baseName);
31
+ }
1
32
  export function matchesSensitivePath(filePath, patterns) {
2
33
  const normalized = filePath.replaceAll('\\', '/');
3
34
  const segments = normalized.split('/');
4
35
  const baseName = segments.at(-1) ?? normalized;
5
36
  for (const pattern of patterns) {
6
37
  const normalizedPattern = pattern.replaceAll('\\', '/');
7
- if (normalizedPattern.includes('**')) {
8
- const parts = normalizedPattern.split('**').map((part) => part.replace(/^\/+|\/+$/g, ''));
9
- const prefix = parts[0]?.replace(/\/+$/, '') ?? '';
10
- const suffix = parts[1]?.replace(/^\/+/, '') ?? '';
11
- if (prefix && !normalized.startsWith(prefix)) {
12
- continue;
13
- }
14
- if (suffix && !normalized.includes(suffix)) {
15
- continue;
16
- }
17
- if (prefix || suffix) {
18
- return true;
19
- }
20
- }
21
38
  if (normalizedPattern.includes('*')) {
22
- const regex = new RegExp(`^${normalizedPattern.replaceAll('.', '\\.').replaceAll('*', '.*')}$`);
23
- if (regex.test(normalized) || regex.test(baseName)) {
39
+ if (globPatternMatches(normalizedPattern, normalized, baseName)) {
24
40
  return true;
25
41
  }
26
42
  continue;
@@ -8,9 +8,9 @@ export interface IntegrityManifest {
8
8
  }
9
9
  export declare function sha256File(filePath: string): Promise<string>;
10
10
  export declare function integrityManifestPath(layout: AdapterLayout, repoRoot: string): string;
11
- export declare function runtimeIntegrityFiles(_layout: AdapterLayout, paths: ScopedPaths): string[];
11
+ export declare function runtimeIntegrityFiles(layout: AdapterLayout, paths: ScopedPaths): string[];
12
12
  export declare function writeIntegrityManifest(repoRoot: string, layout: AdapterLayout, filePaths: string[]): Promise<void>;
13
- export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout): Promise<{
13
+ export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout, requiredFilePaths?: string[]): Promise<{
14
14
  ok: boolean;
15
15
  mismatches: string[];
16
16
  }>;
@@ -3,8 +3,24 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { getAdapterLayout } from '../adapters/layouts/index.js';
6
- import { resolveScopedPaths } from '../adapters/layouts/scope.js';
6
+ import { isPathInside, resolveScopedPaths } from '../adapters/layouts/scope.js';
7
7
  import { resolveAdapterName } from '../config-io.js';
8
+ const GLOBAL_INTEGRITY_PREFIX = '@global/';
9
+ function portableRelativePath(from, to) {
10
+ return path.relative(from, to).split(path.sep).join('/');
11
+ }
12
+ function integrityManifestFileKey(repoRoot, layout, filePath) {
13
+ const absolutePath = path.resolve(filePath);
14
+ if (isPathInside(absolutePath, repoRoot)) {
15
+ return portableRelativePath(repoRoot, absolutePath);
16
+ }
17
+ const globalPaths = resolveScopedPaths(layout, 'global', repoRoot);
18
+ const globalAgentDir = path.dirname(globalPaths.hooksSettingsPath);
19
+ if (isPathInside(absolutePath, globalAgentDir)) {
20
+ return `${GLOBAL_INTEGRITY_PREFIX}${portableRelativePath(globalAgentDir, absolutePath)}`;
21
+ }
22
+ throw new Error(`Integrity file is outside the repository and global adapter root: ${filePath}`);
23
+ }
8
24
  export async function sha256File(filePath) {
9
25
  const content = await readFile(filePath);
10
26
  return createHash('sha256').update(content).digest('hex');
@@ -12,11 +28,8 @@ export async function sha256File(filePath) {
12
28
  export function integrityManifestPath(layout, repoRoot) {
13
29
  return path.join(layout.repoLocalStateDir(repoRoot), 'integrity-manifest.json');
14
30
  }
15
- export function runtimeIntegrityFiles(_layout, paths) {
31
+ export function runtimeIntegrityFiles(layout, paths) {
16
32
  const files = [paths.configPath];
17
- if (paths.scope !== 'project') {
18
- return files;
19
- }
20
33
  const hooksDir = paths.hooksDir;
21
34
  const runtimeDir = paths.runtimeDir;
22
35
  return [
@@ -28,7 +41,9 @@ export function runtimeIntegrityFiles(_layout, paths) {
28
41
  path.join(hooksDir, 'belay-audit.mjs'),
29
42
  path.join(hooksDir, 'belay-runner'),
30
43
  path.join(hooksDir, 'belay-runner.cmd'),
44
+ ...(layout.name === 'cursor' ? [path.join(hooksDir, 'belay-runner.ps1')] : []),
31
45
  path.join(runtimeDir, 'core.mjs'),
46
+ ...(layout.name === 'cursor' ? [path.join(runtimeDir, 'dispatcher.mjs')] : []),
32
47
  ];
33
48
  }
34
49
  export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
@@ -37,8 +52,8 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
37
52
  if (!existsSync(filePath)) {
38
53
  continue;
39
54
  }
40
- const relativePath = path.relative(repoRoot, filePath);
41
- files[relativePath] = await sha256File(filePath);
55
+ const manifestKey = integrityManifestFileKey(repoRoot, layout, filePath);
56
+ files[manifestKey] = await sha256File(filePath);
42
57
  }
43
58
  const manifest = {
44
59
  version: 1,
@@ -49,22 +64,47 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
49
64
  await mkdir(path.dirname(manifestPath), { recursive: true });
50
65
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
51
66
  }
52
- export async function verifyIntegrityManifest(repoRoot, layout) {
67
+ export async function verifyIntegrityManifest(repoRoot, layout, requiredFilePaths = []) {
53
68
  const manifestPath = integrityManifestPath(layout, repoRoot);
54
69
  if (!existsSync(manifestPath)) {
55
70
  return { ok: false, mismatches: ['missing integrity-manifest.json'] };
56
71
  }
57
72
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
58
73
  const mismatches = [];
59
- for (const [relativePath, expectedHash] of Object.entries(manifest.files ?? {})) {
60
- const absolutePath = path.join(repoRoot, relativePath);
74
+ const requiredPathsByKey = new Map(requiredFilePaths.map((requiredPath) => [
75
+ integrityManifestFileKey(repoRoot, layout, requiredPath),
76
+ path.resolve(requiredPath),
77
+ ]));
78
+ for (const requiredPath of requiredFilePaths) {
79
+ const requiredKey = integrityManifestFileKey(repoRoot, layout, requiredPath);
80
+ if (!Object.hasOwn(manifest.files ?? {}, requiredKey)) {
81
+ mismatches.push(`missing integrity pin ${requiredKey}`);
82
+ }
83
+ }
84
+ for (const [manifestKey, expectedHash] of Object.entries(manifest.files ?? {})) {
85
+ let absolutePath;
86
+ if (manifestKey.startsWith(GLOBAL_INTEGRITY_PREFIX)) {
87
+ const requiredPath = requiredPathsByKey.get(manifestKey);
88
+ if (!requiredPath) {
89
+ mismatches.push(`unrecognized global integrity pin ${manifestKey}`);
90
+ continue;
91
+ }
92
+ absolutePath = requiredPath;
93
+ }
94
+ else {
95
+ absolutePath = path.resolve(repoRoot, manifestKey);
96
+ if (!isPathInside(absolutePath, repoRoot)) {
97
+ mismatches.push(`integrity pin escapes repository ${manifestKey}`);
98
+ continue;
99
+ }
100
+ }
61
101
  if (!existsSync(absolutePath)) {
62
- mismatches.push(`missing ${relativePath}`);
102
+ mismatches.push(`missing ${manifestKey}`);
63
103
  continue;
64
104
  }
65
105
  const actualHash = await sha256File(absolutePath);
66
106
  if (actualHash !== expectedHash) {
67
- mismatches.push(`hash mismatch ${relativePath}`);
107
+ mismatches.push(`hash mismatch ${manifestKey}`);
68
108
  }
69
109
  }
70
110
  return { ok: mismatches.length === 0, mismatches };
@@ -1,7 +1,10 @@
1
1
  import type { GatedActionKind } from './gate-contract.js';
2
2
  import type { ScrubOptions } from './types.js';
3
+ export declare function redactToolInvocationId(value: unknown, rawToolUseId?: string): unknown;
3
4
  /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
4
5
  export declare function subagentFingerprintSource(payload: Record<string, unknown>, scrubOptions: ScrubOptions): unknown;
6
+ /** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
7
+ export declare function fingerprintToolInputSource(payload: Record<string, unknown>): Record<string, unknown>;
5
8
  /**
6
9
  * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
7
10
  * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
@@ -1,4 +1,22 @@
1
1
  import { scrubValue } from './scrub.js';
2
+ export function redactToolInvocationId(value, rawToolUseId) {
3
+ if (typeof value === 'string') {
4
+ return rawToolUseId ? value.replaceAll(rawToolUseId, '<tool-use-id>') : value;
5
+ }
6
+ if (Array.isArray(value)) {
7
+ return value.map((item) => redactToolInvocationId(item, rawToolUseId));
8
+ }
9
+ if (value && typeof value === 'object') {
10
+ const result = {};
11
+ for (const [key, child] of Object.entries(value)) {
12
+ if (key !== 'tool_use_id') {
13
+ result[key] = redactToolInvocationId(child, rawToolUseId);
14
+ }
15
+ }
16
+ return result;
17
+ }
18
+ return value;
19
+ }
2
20
  /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
3
21
  export function subagentFingerprintSource(payload, scrubOptions) {
4
22
  const toolInput = payload.tool_input;
@@ -22,6 +40,14 @@ export function subagentFingerprintSource(payload, scrubOptions) {
22
40
  }
23
41
  return scrubValue(payload, scrubOptions);
24
42
  }
43
+ /** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
44
+ export function fingerprintToolInputSource(payload) {
45
+ const toolInput = payload.tool_input;
46
+ if (!toolInput || typeof toolInput !== 'object') {
47
+ return {};
48
+ }
49
+ return redactToolInvocationId(toolInput, typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined);
50
+ }
25
51
  /**
26
52
  * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
27
53
  * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
@@ -30,14 +56,15 @@ export function fingerprintReplayPayload(kind, payload, scrubOptions) {
30
56
  if (!payload) {
31
57
  return undefined;
32
58
  }
59
+ const replayPayload = redactToolInvocationId(payload, typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined);
33
60
  if (kind === 'tool') {
34
- const toolInput = payload.tool_input;
61
+ const toolInput = replayPayload.tool_input;
35
62
  if (toolInput && typeof toolInput === 'object') {
36
63
  return scrubValue(toolInput, scrubOptions);
37
64
  }
38
65
  }
39
66
  if (kind === 'subagent') {
40
- return subagentFingerprintSource(payload, scrubOptions);
67
+ return subagentFingerprintSource(replayPayload, scrubOptions);
41
68
  }
42
- return scrubValue(payload, scrubOptions);
69
+ return scrubValue(replayPayload, scrubOptions);
43
70
  }
@@ -16,7 +16,7 @@ export function findCommandSubstitutions(command) {
16
16
  index += 1;
17
17
  continue;
18
18
  }
19
- if (char === '\\' && (inSingle || inDouble)) {
19
+ if (char === '\\' && inDouble) {
20
20
  escaping = true;
21
21
  index += 1;
22
22
  continue;
@@ -156,7 +156,7 @@ function extractBalancedParenContent(command, start) {
156
156
  index += 1;
157
157
  continue;
158
158
  }
159
- if (char === '\\' && (inSingle || inDouble)) {
159
+ if (char === '\\' && inDouble) {
160
160
  escaping = true;
161
161
  index += 1;
162
162
  continue;
@@ -1,5 +1,33 @@
1
1
  export declare function isRedirectOperator(token: string): boolean;
2
2
  export declare function isFdDuplication(token: string): boolean;
3
+ export type ShellQuoteMode = 'unquoted' | 'single' | 'double';
4
+ export interface ShellWordPart {
5
+ value: string;
6
+ raw: string;
7
+ start: number;
8
+ end: number;
9
+ quote: ShellQuoteMode;
10
+ hasExpansion: boolean;
11
+ }
12
+ export type ShellToken = {
13
+ kind: 'word';
14
+ value: string;
15
+ raw: string;
16
+ start: number;
17
+ end: number;
18
+ parts: ShellWordPart[];
19
+ } | {
20
+ kind: 'operator';
21
+ value: string;
22
+ raw: string;
23
+ start: number;
24
+ end: number;
25
+ };
26
+ export interface ShellLexResult {
27
+ tokens: ShellToken[];
28
+ complete: boolean;
29
+ }
30
+ export declare function lexShell(input: string): ShellLexResult;
3
31
  export declare function tokenizeShell(input: string): string[];
4
32
  export declare function normalizeShellCommand(command: string, repoRoot: string, normalizeToken: (t: string, r: string) => string): string;
5
33
  export declare function splitTopLevelSegments(tokens: string[]): string[][];
@@ -67,61 +67,143 @@ export function isRedirectOperator(token) {
67
67
  export function isFdDuplication(token) {
68
68
  return FD_DUPLICATION_PATTERN.test(token);
69
69
  }
70
- export function tokenizeShell(input) {
70
+ export function lexShell(input) {
71
71
  const tokens = [];
72
- let buffer = '';
72
+ let value = '';
73
+ let wordStart = null;
74
+ let parts = [];
73
75
  let quote = null;
74
- let escaping = false;
75
- const flush = () => {
76
- if (buffer.length > 0) {
77
- tokens.push(buffer);
78
- buffer = '';
76
+ let quoteStart = -1;
77
+ let quoteHadContent = false;
78
+ let complete = true;
79
+ const startWord = (index) => {
80
+ wordStart ??= index;
81
+ };
82
+ const append = (decoded, start, end, mode, hasExpansion) => {
83
+ startWord(start);
84
+ value += decoded;
85
+ const previous = parts.at(-1);
86
+ if (previous &&
87
+ previous.quote === mode &&
88
+ previous.hasExpansion === hasExpansion &&
89
+ previous.end === start) {
90
+ previous.value += decoded;
91
+ previous.raw += input.slice(start, end);
92
+ previous.end = end;
93
+ return;
79
94
  }
95
+ parts.push({
96
+ value: decoded,
97
+ raw: input.slice(start, end),
98
+ start,
99
+ end,
100
+ quote: mode,
101
+ hasExpansion,
102
+ });
103
+ };
104
+ const flushWord = (end) => {
105
+ if (wordStart === null)
106
+ return;
107
+ tokens.push({
108
+ kind: 'word',
109
+ value,
110
+ raw: input.slice(wordStart, end),
111
+ start: wordStart,
112
+ end,
113
+ parts,
114
+ });
115
+ value = '';
116
+ wordStart = null;
117
+ parts = [];
118
+ };
119
+ const pushOperator = (token, start, end) => {
120
+ tokens.push({ kind: 'operator', value: token, raw: input.slice(start, end), start, end });
80
121
  };
81
122
  for (let index = 0; index < input.length; index += 1) {
82
- const char = input[index];
83
- if (escaping) {
84
- buffer += char;
85
- escaping = false;
86
- continue;
87
- }
88
- if (char === '\\') {
89
- escaping = true;
123
+ const char = input[index] ?? '';
124
+ if (quote === 'single') {
125
+ if (char === "'") {
126
+ if (!quoteHadContent)
127
+ append('', quoteStart, index + 1, 'single', false);
128
+ quote = null;
129
+ }
130
+ else {
131
+ append(char, index, index + 1, 'single', false);
132
+ quoteHadContent = true;
133
+ }
90
134
  continue;
91
135
  }
92
- if (quote) {
93
- if (char === quote) {
136
+ if (quote === 'double') {
137
+ if (char === '"') {
138
+ if (!quoteHadContent)
139
+ append('', quoteStart, index + 1, 'double', false);
94
140
  quote = null;
141
+ continue;
95
142
  }
96
- else {
97
- buffer += char;
143
+ if (char === '\\') {
144
+ const next = input[index + 1];
145
+ if (next === undefined) {
146
+ append('\\', index, index + 1, 'double', false);
147
+ complete = false;
148
+ continue;
149
+ }
150
+ if (next === '$' || next === '`' || next === '"' || next === '\\' || next === '\n') {
151
+ append(next === '\n' ? '' : next, index, index + 2, 'double', false);
152
+ quoteHadContent = true;
153
+ index += 1;
154
+ continue;
155
+ }
156
+ append('\\', index, index + 1, 'double', false);
157
+ quoteHadContent = true;
158
+ continue;
98
159
  }
160
+ append(char, index, index + 1, 'double', char === '$' || char === '`');
161
+ quoteHadContent = true;
99
162
  continue;
100
163
  }
101
- if (char === '"' || char === "'") {
102
- quote = char;
164
+ if (char === "'" || char === '"') {
165
+ startWord(index);
166
+ quote = char === "'" ? 'single' : 'double';
167
+ quoteStart = index;
168
+ quoteHadContent = false;
169
+ continue;
170
+ }
171
+ if (char === '\\') {
172
+ const next = input[index + 1];
173
+ if (next === undefined) {
174
+ append('\\', index, index + 1, 'unquoted', false);
175
+ complete = false;
176
+ continue;
177
+ }
178
+ append(next, index, index + 2, 'unquoted', false);
179
+ index += 1;
103
180
  continue;
104
181
  }
105
182
  const operator = readShellOperator(input, index);
106
183
  if (operator) {
107
- flush();
108
- tokens.push(operator.token);
184
+ flushWord(index);
185
+ pushOperator(operator.token, index, index + operator.length);
109
186
  index += operator.length - 1;
110
187
  continue;
111
188
  }
112
189
  if (char === '\n' || char === '\r') {
113
- flush();
114
- tokens.push(';');
190
+ flushWord(index);
191
+ pushOperator(';', index, index + 1);
115
192
  continue;
116
193
  }
117
194
  if (/\s/.test(char)) {
118
- flush();
195
+ flushWord(index);
119
196
  continue;
120
197
  }
121
- buffer += char;
198
+ append(char, index, index + 1, 'unquoted', char === '$' || char === '`');
122
199
  }
123
- flush();
124
- return tokens;
200
+ if (quote !== null)
201
+ complete = false;
202
+ flushWord(input.length);
203
+ return { tokens, complete };
204
+ }
205
+ export function tokenizeShell(input) {
206
+ return lexShell(input).tokens.map((token) => token.value);
125
207
  }
126
208
  export function normalizeShellCommand(command, repoRoot, normalizeToken) {
127
209
  const tokens = tokenizeShell(command);
@@ -42,7 +42,9 @@ function hasUnclosedQuote(command) {
42
42
  continue;
43
43
  }
44
44
  if (char === '\\') {
45
- escaping = true;
45
+ if (quote !== "'") {
46
+ escaping = true;
47
+ }
46
48
  continue;
47
49
  }
48
50
  if (quote) {
@@ -0,0 +1,18 @@
1
+ import type { ShellToken } from '../shell-tokenizer.js';
2
+ export type DockerComposeRunInvocation = {
3
+ kind: 'recursive';
4
+ service: string;
5
+ interpreter: string;
6
+ script: string;
7
+ } | {
8
+ kind: 'dynamic';
9
+ service: string;
10
+ signal: string;
11
+ } | {
12
+ kind: 'none';
13
+ } | {
14
+ kind: 'indeterminate';
15
+ signal: 'shell.compose_argv_indeterminate';
16
+ };
17
+ export declare function decodeDockerComposeRun(tokens: readonly ShellToken[]): DockerComposeRunInvocation;
18
+ export declare function decodeDockerComposeRunValues(values: readonly string[]): DockerComposeRunInvocation;
@@ -0,0 +1,136 @@
1
+ import path from 'node:path';
2
+ import { decodeRecursiveInvocation, shellTokensFromValues } from './recursive-invocation.js';
3
+ const COMPOSE_GLOBAL_OPTIONS = new Map([
4
+ ['--all-resources', 0],
5
+ ['--ansi', 1],
6
+ ['--compatibility', 0],
7
+ ['--dry-run', 0],
8
+ ['--env-file', 1],
9
+ ['-f', 1],
10
+ ['--file', 1],
11
+ ['--parallel', 1],
12
+ ['--profile', 1],
13
+ ['--progress', 1],
14
+ ['--project-directory', 1],
15
+ ['-p', 1],
16
+ ['--project-name', 1],
17
+ ]);
18
+ const COMPOSE_RUN_OPTIONS = new Map([
19
+ ['--build', 0],
20
+ ['--cap-add', 1],
21
+ ['--cap-drop', 1],
22
+ ['-d', 0],
23
+ ['--detach', 0],
24
+ ['--entrypoint', 1],
25
+ ['-e', 1],
26
+ ['--env', 1],
27
+ ['--env-from-file', 1],
28
+ ['-i', 0],
29
+ ['--interactive', 0],
30
+ ['-l', 1],
31
+ ['--label', 1],
32
+ ['--name', 1],
33
+ ['--no-deps', 0],
34
+ ['-T', 0],
35
+ ['--no-tty', 0],
36
+ ['-p', 1],
37
+ ['--publish', 1],
38
+ ['--pull', 1],
39
+ ['-q', 0],
40
+ ['--quiet', 0],
41
+ ['--quiet-build', 0],
42
+ ['--quiet-pull', 0],
43
+ ['--remove-orphans', 0],
44
+ ['--rm', 0],
45
+ ['-P', 0],
46
+ ['--service-ports', 0],
47
+ ['--use-aliases', 0],
48
+ ['-u', 1],
49
+ ['--user', 1],
50
+ ['-v', 1],
51
+ ['--volume', 1],
52
+ ['-w', 1],
53
+ ['--workdir', 1],
54
+ ]);
55
+ function parseOptions(words, start, options) {
56
+ let index = start;
57
+ while (index < words.length) {
58
+ const value = words[index]?.value ?? '';
59
+ if (value === '--')
60
+ return { kind: 'ok', index: index + 1 };
61
+ if (!value.startsWith('-') || value === '-')
62
+ return { kind: 'ok', index };
63
+ const equalsIndex = value.indexOf('=');
64
+ const name = equalsIndex === -1 ? value : value.slice(0, equalsIndex);
65
+ const arity = options.get(name);
66
+ if (arity === undefined)
67
+ return { kind: 'indeterminate' };
68
+ if (equalsIndex !== -1) {
69
+ if (!value.startsWith('--') || arity !== 1 || equalsIndex === value.length - 1) {
70
+ return { kind: 'indeterminate' };
71
+ }
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (arity === 1) {
76
+ if (!words[index + 1])
77
+ return { kind: 'indeterminate' };
78
+ index += 2;
79
+ continue;
80
+ }
81
+ index += 1;
82
+ }
83
+ return { kind: 'ok', index };
84
+ }
85
+ export function decodeDockerComposeRun(tokens) {
86
+ if (tokens.some((token) => token.kind === 'operator'))
87
+ return { kind: 'none' };
88
+ const words = tokens.filter((token) => token.kind === 'word');
89
+ const head = path.basename(words[0]?.value ?? '');
90
+ let index;
91
+ if (head === 'docker-compose') {
92
+ index = 1;
93
+ }
94
+ else if (head === 'docker' && words[1]?.value === 'compose') {
95
+ index = 2;
96
+ }
97
+ else {
98
+ return { kind: 'none' };
99
+ }
100
+ const globalOptions = parseOptions(words, index, COMPOSE_GLOBAL_OPTIONS);
101
+ if (globalOptions.kind === 'indeterminate') {
102
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
103
+ }
104
+ index = globalOptions.index;
105
+ if (words[index]?.value !== 'run')
106
+ return { kind: 'none' };
107
+ const runOptions = parseOptions(words, index + 1, COMPOSE_RUN_OPTIONS);
108
+ if (runOptions.kind === 'indeterminate') {
109
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
110
+ }
111
+ index = runOptions.index;
112
+ const service = words[index]?.value;
113
+ if (!service)
114
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
115
+ const command = words.slice(index + 1);
116
+ if (command.length === 0)
117
+ return { kind: 'none' };
118
+ const recursive = decodeRecursiveInvocation(command);
119
+ if (recursive.kind === 'static') {
120
+ return {
121
+ kind: 'recursive',
122
+ service,
123
+ interpreter: recursive.interpreter,
124
+ script: recursive.script,
125
+ };
126
+ }
127
+ if (recursive.kind === 'dynamic')
128
+ return { kind: 'dynamic', service, signal: recursive.signal };
129
+ if (recursive.kind === 'indeterminate') {
130
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
131
+ }
132
+ return { kind: 'none' };
133
+ }
134
+ export function decodeDockerComposeRunValues(values) {
135
+ return decodeDockerComposeRun(shellTokensFromValues(values, { detectExpansion: false }));
136
+ }