opencode-commit-guard 1.0.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.
package/src/shell.ts ADDED
@@ -0,0 +1,671 @@
1
+ import type { GitCommitInvocation, ShellToken } from "./types.js"
2
+
3
+ const gitGlobalOptionsWithArg = new Set([
4
+ "-C",
5
+ "-c",
6
+ "--git-dir",
7
+ "--work-tree",
8
+ "--namespace",
9
+ "--exec-path",
10
+ "--super-prefix",
11
+ ])
12
+
13
+ const commandPrefixes = new Set([
14
+ "!",
15
+ "do",
16
+ "elif",
17
+ "else",
18
+ "if",
19
+ "then",
20
+ "time",
21
+ "until",
22
+ "while",
23
+ ])
24
+
25
+ const commitLongOptionsWithArg = new Set([
26
+ "--author",
27
+ "--cleanup",
28
+ "--date",
29
+ "--fixup",
30
+ "--pathspec-from-file",
31
+ "--reedit-message",
32
+ "--reuse-message",
33
+ "--squash",
34
+ "--template",
35
+ "--trailer",
36
+ ])
37
+
38
+ interface CommandSubstitution {
39
+ readonly content: string
40
+ readonly end: number
41
+ }
42
+
43
+ interface CommandStart {
44
+ readonly index: number
45
+ readonly directoryChanges: readonly string[]
46
+ }
47
+
48
+ function isWordSeparator(character: string): boolean {
49
+ return (
50
+ character === " " ||
51
+ character === "\t" ||
52
+ character === "\r" ||
53
+ character === "\n" ||
54
+ character === ";" ||
55
+ character === "&" ||
56
+ character === "|" ||
57
+ character === "(" ||
58
+ character === ")" ||
59
+ character === "{" ||
60
+ character === "}"
61
+ )
62
+ }
63
+
64
+ function consumeCommandSubstitution(command: string, start: number): CommandSubstitution {
65
+ let depth = 1
66
+ let quote: "'" | '"' | undefined
67
+ let i = start + 2
68
+ let atWordStart = true
69
+
70
+ while (i < command.length) {
71
+ const character = command[i]
72
+
73
+ if (character === undefined) break
74
+
75
+ if (quote === "'") {
76
+ if (character === "'") quote = undefined
77
+ i++
78
+ continue
79
+ }
80
+
81
+ if (quote === '"') {
82
+ if (character === "\\") {
83
+ i += i + 1 < command.length ? 2 : 1
84
+ } else if (character === '"') {
85
+ quote = undefined
86
+ i++
87
+ } else if (character === "$" && command[i + 1] === "(") {
88
+ i = consumeCommandSubstitution(command, i).end
89
+ } else {
90
+ i++
91
+ }
92
+
93
+ continue
94
+ }
95
+
96
+ if (character === "\\") {
97
+ if (command[i + 1] !== "\n") atWordStart = false
98
+ i += i + 1 < command.length ? 2 : 1
99
+ continue
100
+ }
101
+
102
+ if (character === "'") {
103
+ quote = "'"
104
+ atWordStart = false
105
+ i++
106
+ continue
107
+ }
108
+
109
+ if (character === '"') {
110
+ quote = '"'
111
+ atWordStart = false
112
+ i++
113
+ continue
114
+ }
115
+
116
+ if (character === "#" && atWordStart) {
117
+ while (i < command.length && command[i] !== "\n") i++
118
+ continue
119
+ }
120
+
121
+ if (character === "$" && command[i + 1] === "(") {
122
+ i = consumeCommandSubstitution(command, i).end
123
+ atWordStart = false
124
+ continue
125
+ }
126
+
127
+ if (character === "(") {
128
+ depth++
129
+ i++
130
+ atWordStart = true
131
+ continue
132
+ }
133
+
134
+ if (character === ")") {
135
+ depth--
136
+
137
+ if (depth === 0) {
138
+ return { content: command.slice(start + 2, i), end: i + 1 }
139
+ }
140
+
141
+ atWordStart = true
142
+ }
143
+
144
+ if (isWordSeparator(character)) atWordStart = true
145
+ else atWordStart = false
146
+ i++
147
+ }
148
+
149
+ return { content: command.slice(start + 2), end: command.length }
150
+ }
151
+
152
+ function pushWord(
153
+ tokens: ShellToken[],
154
+ value: string,
155
+ substitutions: readonly string[],
156
+ ): void {
157
+ if (substitutions.length > 0) {
158
+ tokens.push({ type: "word", value, substitutions })
159
+ } else {
160
+ tokens.push({ type: "word", value })
161
+ }
162
+ }
163
+
164
+ export function tokenizeShell(command: string): ShellToken[] {
165
+ const tokens: ShellToken[] = []
166
+ let i = 0
167
+
168
+ while (i < command.length) {
169
+ const character = command[i]
170
+
171
+ if (character === undefined) break
172
+
173
+ if (character === " " || character === "\t" || character === "\r") {
174
+ i++
175
+ continue
176
+ }
177
+
178
+ if (character === "\\" && command[i + 1] === "\n") {
179
+ i += 2
180
+ continue
181
+ }
182
+
183
+ if (character === "#") {
184
+ while (i < command.length && command[i] !== "\n") i++
185
+ continue
186
+ }
187
+
188
+ const twoCharacters = command.slice(i, i + 2)
189
+
190
+ if (
191
+ twoCharacters === "&&" ||
192
+ twoCharacters === "||" ||
193
+ twoCharacters === "|&" ||
194
+ twoCharacters === ";;"
195
+ ) {
196
+ tokens.push({ type: "operator", value: twoCharacters })
197
+ i += 2
198
+ continue
199
+ }
200
+
201
+ if (
202
+ character === "|" ||
203
+ character === ";" ||
204
+ character === "&" ||
205
+ character === "\n" ||
206
+ character === "(" ||
207
+ character === ")" ||
208
+ character === "{" ||
209
+ character === "}"
210
+ ) {
211
+ tokens.push({ type: "operator", value: character })
212
+ i++
213
+ continue
214
+ }
215
+
216
+ const redirectMatch = command.slice(i).match(/^(\d+)?(<<<|>>|<<|>&|<&|>\||>|<)(?:-|\d+)?/)
217
+
218
+ if (redirectMatch !== null && redirectMatch[0] !== undefined) {
219
+ tokens.push({ type: "redirect", value: redirectMatch[0] })
220
+ i += redirectMatch[0].length
221
+ continue
222
+ }
223
+
224
+ let value = ""
225
+ const substitutions: string[] = []
226
+
227
+ while (i < command.length) {
228
+ const current = command[i]
229
+
230
+ if (current === undefined || isWordSeparator(current)) break
231
+
232
+ if (current === "\\" && command[i + 1] === "\n") {
233
+ i += 2
234
+ continue
235
+ }
236
+
237
+ if (current === "\\") {
238
+ if (i + 1 < command.length) {
239
+ value += command[i + 1]
240
+ i += 2
241
+ } else {
242
+ value += "\\"
243
+ i++
244
+ }
245
+
246
+ continue
247
+ }
248
+
249
+ if (current === "$" && command[i + 1] === "'") {
250
+ i += 2
251
+
252
+ while (i < command.length) {
253
+ const ansiCharacter = command[i]
254
+
255
+ if (ansiCharacter === undefined) break
256
+
257
+ if (ansiCharacter === "'") {
258
+ i++
259
+ break
260
+ }
261
+
262
+ if (ansiCharacter === "\\" && i + 1 < command.length) {
263
+ const escaped = command[i + 1]
264
+
265
+ if (escaped === "n") value += "\n"
266
+ else if (escaped === "t") value += "\t"
267
+ else if (escaped === "r") value += "\r"
268
+ else if (escaped === "\\") value += "\\"
269
+ else if (escaped === "'") value += "'"
270
+ else if (escaped === '"') value += '"'
271
+ else value += escaped
272
+ i += 2
273
+ } else {
274
+ value += ansiCharacter
275
+ i++
276
+ }
277
+ }
278
+
279
+ continue
280
+ }
281
+
282
+ if (current === "'") {
283
+ i++
284
+
285
+ while (i < command.length && command[i] !== "'") {
286
+ value += command[i]
287
+ i++
288
+ }
289
+
290
+ if (command[i] === "'") i++
291
+ continue
292
+ }
293
+
294
+ if (current === '"') {
295
+ i++
296
+
297
+ while (i < command.length && command[i] !== '"') {
298
+ const quotedCharacter = command[i]
299
+
300
+ if (quotedCharacter === undefined) break
301
+
302
+ if (quotedCharacter === "\\" && i + 1 < command.length) {
303
+ const escaped = command[i + 1]
304
+
305
+ if (escaped === "\n") {
306
+ i += 2
307
+ } else if (escaped === '"' || escaped === "\\" || escaped === "$" || escaped === "`") {
308
+ value += escaped
309
+ i += 2
310
+ } else {
311
+ value += `\\${escaped}`
312
+ i += 2
313
+ }
314
+ } else if (quotedCharacter === "$" && command[i + 1] === "(") {
315
+ const substitution = consumeCommandSubstitution(command, i)
316
+ value += command.slice(i, substitution.end)
317
+ substitutions.push(substitution.content)
318
+ i = substitution.end
319
+ } else {
320
+ value += quotedCharacter
321
+ i++
322
+ }
323
+ }
324
+
325
+ if (command[i] === '"') i++
326
+ continue
327
+ }
328
+
329
+ if (current === "$" && command[i + 1] === "(") {
330
+ const substitution = consumeCommandSubstitution(command, i)
331
+ value += command.slice(i, substitution.end)
332
+ substitutions.push(substitution.content)
333
+ i = substitution.end
334
+ continue
335
+ }
336
+
337
+ const nextRedirect = command.slice(i).match(/^(\d+)?(<<<|>>|<<|>&|<&|>\||>|<)(?:-|\d+)?/)
338
+
339
+ if (nextRedirect !== null) break
340
+
341
+ value += current
342
+ i++
343
+ }
344
+
345
+ pushWord(tokens, value, substitutions)
346
+ }
347
+
348
+ return tokens
349
+ }
350
+
351
+ function splitSimpleCommands(tokens: readonly ShellToken[]): ShellToken[][] {
352
+ const commands: ShellToken[][] = []
353
+ let current: ShellToken[] = []
354
+
355
+ for (const token of tokens) {
356
+ if (token.type === "operator") {
357
+ if (current.length > 0) commands.push(current)
358
+ current = []
359
+ } else {
360
+ current.push(token)
361
+ }
362
+ }
363
+
364
+ if (current.length > 0) commands.push(current)
365
+
366
+ return commands
367
+ }
368
+
369
+ function extractCommandWords(tokens: readonly ShellToken[]): string[] {
370
+ const words: string[] = []
371
+ let i = 0
372
+
373
+ while (i < tokens.length) {
374
+ const token = tokens[i]
375
+
376
+ if (token === undefined) break
377
+
378
+ if (token.type === "redirect") {
379
+ if (!token.value.includes("&") && tokens[i + 1]?.type === "word") i += 2
380
+ else i++
381
+ continue
382
+ }
383
+
384
+ if (token.type === "word") {
385
+ words.push(token.value)
386
+ }
387
+
388
+ i++
389
+ }
390
+
391
+ return words
392
+ }
393
+
394
+ function isAssignment(word: string): boolean {
395
+ return /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(word)
396
+ }
397
+
398
+ function skipAssignments(words: readonly string[], start: number): number {
399
+ let index = start
400
+
401
+ while (words[index] !== undefined && isAssignment(words[index])) index++
402
+
403
+ return index
404
+ }
405
+
406
+ function findCommandStart(words: readonly string[]): CommandStart {
407
+ let index = skipAssignments(words, 0)
408
+ const directoryChanges: string[] = []
409
+
410
+ while (index < words.length) {
411
+ const word = words[index]
412
+
413
+ if (word !== undefined && commandPrefixes.has(word)) {
414
+ index = skipAssignments(words, index + 1)
415
+ continue
416
+ }
417
+
418
+ if (word === "env") {
419
+ index++
420
+
421
+ while (index < words.length) {
422
+ const argument = words[index]
423
+
424
+ if (argument === undefined) break
425
+
426
+ if (argument === "--") {
427
+ index++
428
+ break
429
+ }
430
+
431
+ if (argument === "-u" || argument === "--unset") {
432
+ index += 2
433
+ } else if (argument === "-C" || argument === "--chdir") {
434
+ const directory = words[index + 1]
435
+
436
+ if (directory !== undefined) {
437
+ directoryChanges.push(directory)
438
+ }
439
+
440
+ index += 2
441
+ } else if (argument.startsWith("--chdir=")) {
442
+ directoryChanges.push(argument.slice("--chdir=".length))
443
+ index++
444
+ } else if (/^-C.+/.test(argument)) {
445
+ directoryChanges.push(argument.slice(2))
446
+ index++
447
+ } else if (argument.startsWith("--unset=") || /^-u.+/.test(argument)) {
448
+ index++
449
+ } else if (argument.startsWith("-")) {
450
+ index++
451
+ } else if (isAssignment(argument)) {
452
+ index++
453
+ } else {
454
+ break
455
+ }
456
+ }
457
+
458
+ index = skipAssignments(words, index)
459
+ continue
460
+ }
461
+
462
+ if (word === "exec") {
463
+ index++
464
+
465
+ while (index < words.length) {
466
+ const argument = words[index]
467
+
468
+ if (argument === "--") {
469
+ index++
470
+ break
471
+ }
472
+
473
+ if (argument === "-a") index += 2
474
+ else if (argument !== undefined && argument.startsWith("-")) index++
475
+ else break
476
+ }
477
+
478
+ index = skipAssignments(words, index)
479
+ continue
480
+ }
481
+
482
+ if (word === "command" || word === "nohup" || word === "builtin") {
483
+ index++
484
+
485
+ while (words[index]?.startsWith("-")) index++
486
+ index = skipAssignments(words, index)
487
+ continue
488
+ }
489
+
490
+ break
491
+ }
492
+
493
+ return { index, directoryChanges }
494
+ }
495
+
496
+ function extractInvocation(
497
+ words: readonly string[],
498
+ commandStart: CommandStart,
499
+ ): GitCommitInvocation | undefined {
500
+ let wordIndex = commandStart.index
501
+ const executable = words[wordIndex]
502
+
503
+ if (executable === undefined || (executable !== "git" && !executable.endsWith("/git"))) return undefined
504
+ wordIndex++
505
+ const directoryChanges = [...commandStart.directoryChanges]
506
+
507
+ let subcommand: string | undefined
508
+
509
+ while (wordIndex < words.length) {
510
+ const argument = words[wordIndex]
511
+
512
+ if (argument === undefined) break
513
+
514
+ if (argument === "--") {
515
+ subcommand = words[wordIndex + 1]
516
+ wordIndex += 2
517
+ break
518
+ }
519
+
520
+ if (gitGlobalOptionsWithArg.has(argument)) {
521
+ const optionValue = words[wordIndex + 1]
522
+
523
+ if (argument === "-C" && optionValue !== undefined) {
524
+ directoryChanges.push(optionValue)
525
+ }
526
+
527
+ wordIndex += 2
528
+ continue
529
+ }
530
+
531
+ if (
532
+ argument.startsWith("--git-dir=") ||
533
+ argument.startsWith("--work-tree=") ||
534
+ argument.startsWith("--namespace=") ||
535
+ argument.startsWith("--exec-path=") ||
536
+ argument.startsWith("--super-prefix=") ||
537
+ argument.startsWith("-c")
538
+ ) {
539
+ wordIndex++
540
+ continue
541
+ }
542
+
543
+ if (argument.startsWith("-C")) {
544
+ directoryChanges.push(argument.slice(2))
545
+ wordIndex++
546
+ continue
547
+ }
548
+
549
+ if (argument.startsWith("-")) {
550
+ wordIndex++
551
+ continue
552
+ }
553
+
554
+ subcommand = argument
555
+ wordIndex++
556
+ break
557
+ }
558
+
559
+ if (subcommand !== "commit") return undefined
560
+
561
+ const messages: string[] = []
562
+ const filePaths: string[] = []
563
+ let hasSignoffFlag = false
564
+ let isAmend = false
565
+ let hasNoEdit = false
566
+ let isHelp = false
567
+
568
+ const collectMessage = (word: string | undefined): void => {
569
+ if (word === undefined) return
570
+ messages.push(word)
571
+ }
572
+
573
+ const collectFile = (word: string | undefined): void => {
574
+ if (word === undefined) return
575
+ filePaths.push(word)
576
+ }
577
+
578
+ for (let index = wordIndex; index < words.length; index++) {
579
+ const argument = words[index]
580
+
581
+ if (argument === undefined) break
582
+
583
+ if (argument === "--") break
584
+
585
+ if (argument === "-h" || argument === "--help") {
586
+ isHelp = true
587
+ } else if (argument === "--amend") {
588
+ isAmend = true
589
+ } else if (argument === "--no-edit") {
590
+ hasNoEdit = true
591
+ } else if (argument === "--edit") {
592
+ hasNoEdit = false
593
+ } else if (argument === "-s" || argument === "--signoff") {
594
+ hasSignoffFlag = true
595
+ } else if (argument === "--no-signoff") {
596
+ hasSignoffFlag = false
597
+ } else if (argument === "-m" || argument === "--message") {
598
+ index++
599
+ collectMessage(words[index])
600
+ } else if (argument.startsWith("--message=")) {
601
+ collectMessage(argument.slice("--message=".length))
602
+ } else if (argument === "-F" || argument === "--file") {
603
+ index++
604
+ collectFile(words[index])
605
+ } else if (argument.startsWith("--file=")) {
606
+ collectFile(argument.slice("--file=".length))
607
+ } else if (commitLongOptionsWithArg.has(argument)) {
608
+ index++
609
+ } else if (argument.startsWith("-") && !argument.startsWith("--") && argument.length > 1) {
610
+ for (let characterIndex = 1; characterIndex < argument.length; characterIndex++) {
611
+ const option = argument[characterIndex]
612
+
613
+ if (option === "s") {
614
+ hasSignoffFlag = true
615
+ } else if (option === "h") {
616
+ isHelp = true
617
+ } else if (option === "e") {
618
+ hasNoEdit = false
619
+ } else if (option === "m" || option === "F") {
620
+ const attached = argument.slice(characterIndex + 1)
621
+
622
+ const input = attached.length > 0
623
+ ? attached
624
+ : words[++index]
625
+
626
+ if (option === "m") collectMessage(input)
627
+ else collectFile(input)
628
+ break
629
+ } else if (option === "S") {
630
+ break
631
+ } else if (option === "u") {
632
+ break
633
+ } else if (option === "c" || option === "C" || option === "t") {
634
+ if (argument.slice(characterIndex + 1).length === 0) index++
635
+ break
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ return {
642
+ messages,
643
+ filePaths,
644
+ hasSignoffFlag,
645
+ isAmend,
646
+ hasNoEdit,
647
+ isHelp,
648
+ directoryChanges,
649
+ }
650
+ }
651
+
652
+ export function extractGitCommits(command: string): GitCommitInvocation[] {
653
+ const tokens = tokenizeShell(command)
654
+ const invocations: GitCommitInvocation[] = []
655
+
656
+ for (const commandTokens of splitSimpleCommands(tokens)) {
657
+ const words = extractCommandWords(commandTokens)
658
+ const commandStart = findCommandStart(words)
659
+ const invocation = extractInvocation(words, commandStart)
660
+
661
+ if (invocation !== undefined) invocations.push(invocation)
662
+ }
663
+
664
+ for (const token of tokens) {
665
+ for (const substitution of token.substitutions ?? []) {
666
+ invocations.push(...extractGitCommits(substitution))
667
+ }
668
+ }
669
+
670
+ return invocations
671
+ }
package/src/types.ts ADDED
@@ -0,0 +1,60 @@
1
+ export type JsonPrimitive = string | number | boolean | null
2
+
3
+ export type JsonValue =
4
+ | JsonPrimitive
5
+ | JsonValue[]
6
+ | { readonly [key: string]: JsonValue | undefined }
7
+
8
+ export function isJSONString(value: JsonValue | undefined): value is string {
9
+ return Object.prototype.toString.call(value) === "[object String]"
10
+ }
11
+
12
+ export function isJSONNumber(value: JsonValue | undefined): value is number {
13
+ return Object.prototype.toString.call(value) === "[object Number]"
14
+ }
15
+
16
+ export function isJSONBoolean(value: JsonValue | undefined): value is boolean {
17
+ return Object.prototype.toString.call(value) === "[object Boolean]"
18
+ }
19
+
20
+ export function isRecord(value: JsonValue | undefined): value is Record<string, JsonValue> {
21
+ return value instanceof Object && !Array.isArray(value)
22
+ }
23
+
24
+ export interface CommitGuardConfig {
25
+ readonly requireScope: boolean
26
+ readonly allowedScopes?: readonly string[]
27
+ readonly maxLineLength: number
28
+ readonly requireSignoff: boolean
29
+ }
30
+
31
+ export const defaultConfig: CommitGuardConfig = {
32
+ requireScope: true,
33
+ allowedScopes: undefined,
34
+ maxLineLength: 72,
35
+ requireSignoff: true,
36
+ }
37
+
38
+ export type ShellTokenType = "word" | "operator" | "redirect"
39
+
40
+ export interface ShellToken {
41
+ readonly type: ShellTokenType
42
+ readonly value: string
43
+ readonly substitutions?: readonly string[]
44
+ }
45
+
46
+ export interface GitCommitInvocation {
47
+ readonly messages: readonly string[]
48
+ readonly filePaths: readonly string[]
49
+ readonly hasSignoffFlag: boolean
50
+ readonly isAmend: boolean
51
+ readonly hasNoEdit?: boolean
52
+ readonly isHelp: boolean
53
+ readonly directoryChanges?: readonly string[]
54
+ }
55
+
56
+ export interface OverlongLine {
57
+ readonly lineNumber: number
58
+ readonly length: number
59
+ readonly text: string
60
+ }