repo-contract 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 (145) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +967 -0
  4. package/dist/.dts/config/define-repo-contract.d.ts +36 -0
  5. package/dist/.dts/config/define-repo-contract.d.ts.map +1 -0
  6. package/dist/.dts/config/tokenize-command.d.ts +33 -0
  7. package/dist/.dts/config/tokenize-command.d.ts.map +1 -0
  8. package/dist/.dts/config/validate-config.d.ts +32 -0
  9. package/dist/.dts/config/validate-config.d.ts.map +1 -0
  10. package/dist/.dts/errors.d.ts +155 -0
  11. package/dist/.dts/errors.d.ts.map +1 -0
  12. package/dist/.dts/evidence/build-evidence.d.ts +26 -0
  13. package/dist/.dts/evidence/build-evidence.d.ts.map +1 -0
  14. package/dist/.dts/execution/abort-signals.d.ts +29 -0
  15. package/dist/.dts/execution/abort-signals.d.ts.map +1 -0
  16. package/dist/.dts/execution/concurrency-pool.d.ts +14 -0
  17. package/dist/.dts/execution/concurrency-pool.d.ts.map +1 -0
  18. package/dist/.dts/execution/dependency-scheduler.d.ts +30 -0
  19. package/dist/.dts/execution/dependency-scheduler.d.ts.map +1 -0
  20. package/dist/.dts/execution/process-tree.d.ts +48 -0
  21. package/dist/.dts/execution/process-tree.d.ts.map +1 -0
  22. package/dist/.dts/execution/run-checks.d.ts +29 -0
  23. package/dist/.dts/execution/run-checks.d.ts.map +1 -0
  24. package/dist/.dts/execution/spawn-check.d.ts +30 -0
  25. package/dist/.dts/execution/spawn-check.d.ts.map +1 -0
  26. package/dist/.dts/index.d.ts +13 -0
  27. package/dist/.dts/index.d.ts.map +1 -0
  28. package/dist/.dts/parsing/parse-json.d.ts +8 -0
  29. package/dist/.dts/parsing/parse-json.d.ts.map +1 -0
  30. package/dist/.dts/parsing/parse-output.d.ts +10 -0
  31. package/dist/.dts/parsing/parse-output.d.ts.map +1 -0
  32. package/dist/.dts/parsing/parse-text.d.ts +8 -0
  33. package/dist/.dts/parsing/parse-text.d.ts.map +1 -0
  34. package/dist/.dts/parsing/parse-yaml.d.ts +21 -0
  35. package/dist/.dts/parsing/parse-yaml.d.ts.map +1 -0
  36. package/dist/.dts/policy/run-policies.d.ts +38 -0
  37. package/dist/.dts/policy/run-policies.d.ts.map +1 -0
  38. package/dist/.dts/presets/arethetypeswrong.d.ts +39 -0
  39. package/dist/.dts/presets/arethetypeswrong.d.ts.map +1 -0
  40. package/dist/.dts/presets/broken-links.d.ts +16 -0
  41. package/dist/.dts/presets/broken-links.d.ts.map +1 -0
  42. package/dist/.dts/presets/commitlint.d.ts +22 -0
  43. package/dist/.dts/presets/commitlint.d.ts.map +1 -0
  44. package/dist/.dts/presets/dead-code.d.ts +23 -0
  45. package/dist/.dts/presets/dead-code.d.ts.map +1 -0
  46. package/dist/.dts/presets/duplication.d.ts +14 -0
  47. package/dist/.dts/presets/duplication.d.ts.map +1 -0
  48. package/dist/.dts/presets/e2e.d.ts +4 -0
  49. package/dist/.dts/presets/e2e.d.ts.map +1 -0
  50. package/dist/.dts/presets/format.d.ts +4 -0
  51. package/dist/.dts/presets/format.d.ts.map +1 -0
  52. package/dist/.dts/presets/index.d.ts +31 -0
  53. package/dist/.dts/presets/index.d.ts.map +1 -0
  54. package/dist/.dts/presets/license.d.ts +4 -0
  55. package/dist/.dts/presets/license.d.ts.map +1 -0
  56. package/dist/.dts/presets/lint.d.ts +20 -0
  57. package/dist/.dts/presets/lint.d.ts.map +1 -0
  58. package/dist/.dts/presets/markdownlint.d.ts +23 -0
  59. package/dist/.dts/presets/markdownlint.d.ts.map +1 -0
  60. package/dist/.dts/presets/publint.d.ts +13 -0
  61. package/dist/.dts/presets/publint.d.ts.map +1 -0
  62. package/dist/.dts/presets/security-deps.d.ts +4 -0
  63. package/dist/.dts/presets/security-deps.d.ts.map +1 -0
  64. package/dist/.dts/presets/security-secrets.d.ts +4 -0
  65. package/dist/.dts/presets/security-secrets.d.ts.map +1 -0
  66. package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts +13 -0
  67. package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts.map +1 -0
  68. package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts +23 -0
  69. package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts.map +1 -0
  70. package/dist/.dts/presets/shared/missing-dependency.d.ts +19 -0
  71. package/dist/.dts/presets/shared/missing-dependency.d.ts.map +1 -0
  72. package/dist/.dts/presets/shared/read-json-report.d.ts +33 -0
  73. package/dist/.dts/presets/shared/read-json-report.d.ts.map +1 -0
  74. package/dist/.dts/presets/shared/terminal-status.d.ts +25 -0
  75. package/dist/.dts/presets/shared/terminal-status.d.ts.map +1 -0
  76. package/dist/.dts/presets/shared/vitest-json-policy.d.ts +20 -0
  77. package/dist/.dts/presets/shared/vitest-json-policy.d.ts.map +1 -0
  78. package/dist/.dts/presets/stylelint.d.ts +17 -0
  79. package/dist/.dts/presets/stylelint.d.ts.map +1 -0
  80. package/dist/.dts/presets/test.d.ts +4 -0
  81. package/dist/.dts/presets/test.d.ts.map +1 -0
  82. package/dist/.dts/presets/typecheck.d.ts +4 -0
  83. package/dist/.dts/presets/typecheck.d.ts.map +1 -0
  84. package/dist/.dts/run-repo-contract.d.ts +38 -0
  85. package/dist/.dts/run-repo-contract.d.ts.map +1 -0
  86. package/dist/.dts/types.d.ts +324 -0
  87. package/dist/.dts/types.d.ts.map +1 -0
  88. package/dist/index.cjs +46 -0
  89. package/dist/index.cjs.map +1 -0
  90. package/dist/index.d.cts +1 -0
  91. package/dist/index.d.ts +1 -0
  92. package/dist/index.js +11 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/presets.cjs +30 -0
  95. package/dist/presets.cjs.map +1 -0
  96. package/dist/presets.d.cts +1 -0
  97. package/dist/presets.d.ts +1 -0
  98. package/dist/presets.js +13 -0
  99. package/dist/presets.js.map +1 -0
  100. package/package.json +192 -0
  101. package/presets/package.json +5 -0
  102. package/schemas/evidence.schema.json +253 -0
  103. package/schemas/verdict.schema.json +66 -0
  104. package/src/config/define-repo-contract.ts +38 -0
  105. package/src/config/tokenize-command.ts +214 -0
  106. package/src/config/validate-config.ts +368 -0
  107. package/src/errors.ts +229 -0
  108. package/src/evidence/build-evidence.ts +91 -0
  109. package/src/execution/abort-signals.ts +56 -0
  110. package/src/execution/concurrency-pool.ts +64 -0
  111. package/src/execution/dependency-scheduler.ts +216 -0
  112. package/src/execution/process-tree.ts +107 -0
  113. package/src/execution/run-checks.ts +348 -0
  114. package/src/execution/spawn-check.ts +494 -0
  115. package/src/index.ts +44 -0
  116. package/src/parsing/parse-json.ts +18 -0
  117. package/src/parsing/parse-output.ts +26 -0
  118. package/src/parsing/parse-text.ts +10 -0
  119. package/src/parsing/parse-yaml.ts +40 -0
  120. package/src/policy/run-policies.ts +261 -0
  121. package/src/presets/arethetypeswrong.ts +116 -0
  122. package/src/presets/broken-links.ts +95 -0
  123. package/src/presets/commitlint.ts +77 -0
  124. package/src/presets/dead-code.ts +223 -0
  125. package/src/presets/duplication.ts +137 -0
  126. package/src/presets/e2e.ts +144 -0
  127. package/src/presets/format.ts +25 -0
  128. package/src/presets/index.ts +30 -0
  129. package/src/presets/license.ts +90 -0
  130. package/src/presets/lint.ts +116 -0
  131. package/src/presets/markdownlint.ts +105 -0
  132. package/src/presets/publint.ts +38 -0
  133. package/src/presets/security-deps.ts +142 -0
  134. package/src/presets/security-secrets.ts +93 -0
  135. package/src/presets/shared/error-warning-pass-policy.ts +39 -0
  136. package/src/presets/shared/exit-code-fail-rationale.ts +34 -0
  137. package/src/presets/shared/missing-dependency.ts +31 -0
  138. package/src/presets/shared/read-json-report.ts +46 -0
  139. package/src/presets/shared/terminal-status.ts +70 -0
  140. package/src/presets/shared/vitest-json-policy.ts +95 -0
  141. package/src/presets/stylelint.ts +101 -0
  142. package/src/presets/test.ts +19 -0
  143. package/src/presets/typecheck.ts +25 -0
  144. package/src/run-repo-contract.ts +80 -0
  145. package/src/types.ts +340 -0
@@ -0,0 +1,253 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://maverickcer.github.io/repo-contract/schema/evidence.schema.json",
4
+ "title": "repo-contract Evidence",
5
+ "description": "Machine-readable record of what happened when a repo-contract configuration was executed -- one entry per configured check, describing the command run, its exit status, captured output, and (if requested) parsed output. Says nothing about whether the result was acceptable -- see the paired Verdict schema for that. Generated from src/types.ts's Evidence type -- never hand-authored.",
6
+ "$ref": "#/definitions/EvidenceSchemaSource",
7
+ "definitions": {
8
+ "EvidenceSchemaSource": {
9
+ "$ref": "#/definitions/Evidence"
10
+ },
11
+ "Evidence": {
12
+ "type": "object",
13
+ "properties": {
14
+ "version": {
15
+ "type": "number",
16
+ "const": 1,
17
+ "description": "Schema version of this shape; see VERSIONING.md."
18
+ },
19
+ "startedAt": {
20
+ "type": "string",
21
+ "description": "ISO 8601 timestamp of when the run began."
22
+ },
23
+ "completedAt": {
24
+ "type": "string",
25
+ "description": "ISO 8601 timestamp of when the last check finished."
26
+ },
27
+ "durationMs": {
28
+ "type": "number",
29
+ "description": "Wall-clock time for the run as a whole, in milliseconds."
30
+ },
31
+ "checks": {
32
+ "type": "object",
33
+ "additionalProperties": {
34
+ "$ref": "#/definitions/CheckEvidence"
35
+ },
36
+ "description": "Each configured check's own evidence, keyed by check id."
37
+ }
38
+ },
39
+ "required": [
40
+ "version",
41
+ "startedAt",
42
+ "completedAt",
43
+ "durationMs",
44
+ "checks"
45
+ ],
46
+ "description": "Versioned, immutable record of one complete `runRepoContract` execution -- every configured check's evidence, plus timing for the run as a whole. Says nothing about whether any of it was acceptable; see `Verdict`. Additive fields are a compatible change; changing or removing an existing field requires bumping this version number (see VERSIONING.md)."
47
+ },
48
+ "CheckEvidence": {
49
+ "type": "object",
50
+ "properties": {
51
+ "command": {
52
+ "type": "string",
53
+ "description": "The executable that was actually spawned (after tokenization, if `run` was a string)."
54
+ },
55
+ "args": {
56
+ "type": "array",
57
+ "items": {
58
+ "type": "string"
59
+ },
60
+ "description": "The arguments passed to `command`, exactly as spawned."
61
+ },
62
+ "startedAt": {
63
+ "type": "string",
64
+ "description": "ISO 8601 timestamp of when the process was spawned."
65
+ },
66
+ "completedAt": {
67
+ "type": "string",
68
+ "description": "ISO 8601 timestamp of when the process reached its terminal state."
69
+ },
70
+ "durationMs": {
71
+ "type": "number",
72
+ "description": "Wall-clock time from spawn to termination, in milliseconds."
73
+ },
74
+ "exitCode": {
75
+ "type": [
76
+ "number",
77
+ "null"
78
+ ],
79
+ "description": "`null` when the process never exited normally -- see `signal` and `status`."
80
+ },
81
+ "signal": {
82
+ "anyOf": [
83
+ {
84
+ "$ref": "#/definitions/global.NodeJS.Signals"
85
+ },
86
+ {
87
+ "type": "null"
88
+ }
89
+ ],
90
+ "description": "The signal that terminated the process, if any. `null` for a normal exit or a spawn failure."
91
+ },
92
+ "stdout": {
93
+ "type": "string",
94
+ "description": "The process's raw standard output, captured verbatim up to an internal size cap (10 MiB); content beyond the cap is replaced with a truncation marker."
95
+ },
96
+ "stderr": {
97
+ "type": "string",
98
+ "description": "The process's raw standard error, captured verbatim up to an internal size cap (10 MiB); content beyond the cap is replaced with a truncation marker."
99
+ },
100
+ "status": {
101
+ "$ref": "#/definitions/CheckStatus",
102
+ "description": "Why the process reached its terminal state; see `CheckStatus`."
103
+ },
104
+ "spawnError": {
105
+ "type": "string",
106
+ "description": "Populated only for `status === \"spawn_error\"` -- the underlying Node error message (e.g. \"spawn foo ENOENT\"). Never populated for any other status."
107
+ },
108
+ "spawnErrorCode": {
109
+ "type": "string",
110
+ "description": "Populated only for `status === \"spawn_error\"` -- the underlying Node `ErrnoException`'s structured `.code` (e.g. `\"ENOENT\"`, `\"EACCES\"`), when Node provides one. Never populated for any other status. Distinguishes \"the executable does not exist\" from other spawn failures (permission denied, invalid executable format, etc.) without parsing `spawnError`'s free-text message."
111
+ },
112
+ "output": {
113
+ "$ref": "#/definitions/ParsedOutput%3Cunknown%3E",
114
+ "description": "The parsed interpretation of `stdout`, present only if this check's config requested a `format`."
115
+ }
116
+ },
117
+ "required": [
118
+ "command",
119
+ "args",
120
+ "startedAt",
121
+ "completedAt",
122
+ "durationMs",
123
+ "exitCode",
124
+ "signal",
125
+ "stdout",
126
+ "stderr",
127
+ "status"
128
+ ],
129
+ "description": "What actually happened when one configured check ran. `output` is present only if that check's config requested a format, and is otherwise `undefined` -- a policy narrows with `ctx.result.output?.success` (or an `if (ctx.result.output) { ... }` guard) before reading `.value`/`.error`.\n\n`output.value` is typed `unknown` for every format, including `\"text\"` (even though `parseText` always produces a `string` at runtime) -- neither repo-contract nor TypeScript's generic inference can reliably carry a specific check's own literal `output.format` through to that same check's `policy` parameter once several checks with heterogeneous formats live together in one `checks` record (a real TypeScript inference limitation hit and confirmed during implementation, not a hypothetical -- see specs/decisions/ for the isolated repro). A policy author narrows or casts `.value` themselves, exactly as they already must for `\"json\"`/`\"yaml\"` where no schema knowledge exists either way."
130
+ },
131
+ "global.NodeJS.Signals": {
132
+ "type": "string",
133
+ "enum": [
134
+ "SIGABRT",
135
+ "SIGALRM",
136
+ "SIGBUS",
137
+ "SIGCHLD",
138
+ "SIGCONT",
139
+ "SIGFPE",
140
+ "SIGHUP",
141
+ "SIGILL",
142
+ "SIGINT",
143
+ "SIGIO",
144
+ "SIGIOT",
145
+ "SIGKILL",
146
+ "SIGPIPE",
147
+ "SIGPOLL",
148
+ "SIGPROF",
149
+ "SIGPWR",
150
+ "SIGQUIT",
151
+ "SIGSEGV",
152
+ "SIGSTKFLT",
153
+ "SIGSTOP",
154
+ "SIGSYS",
155
+ "SIGTERM",
156
+ "SIGTRAP",
157
+ "SIGTSTP",
158
+ "SIGTTIN",
159
+ "SIGTTOU",
160
+ "SIGUNUSED",
161
+ "SIGURG",
162
+ "SIGUSR1",
163
+ "SIGUSR2",
164
+ "SIGVTALRM",
165
+ "SIGWINCH",
166
+ "SIGXCPU",
167
+ "SIGXFSZ",
168
+ "SIGBREAK",
169
+ "SIGLOST",
170
+ "SIGINFO"
171
+ ]
172
+ },
173
+ "CheckStatus": {
174
+ "type": "string",
175
+ "enum": [
176
+ "completed",
177
+ "timed_out",
178
+ "signaled",
179
+ "host_terminated",
180
+ "spawn_error",
181
+ "aborted"
182
+ ],
183
+ "description": "Why a check's process ended up in its terminal state. `\"completed\"` means the process ran to exit on its own -- the exit code may still be non-zero, and that is for the check's policy to interpret, never this package. The other five values all mean the process did not exit on its own; repo-contract terminated it, or it was terminated for a reason repo-contract can observe but did not cause.\n\n`\"signaled\"` specifically means a signal repo-contract did *not* itself request -- an externally-caused termination. A check killed because the *host* process running repo-contract received its own SIGINT/SIGTERM (see `run-checks.ts`'s termination-handler cleanup) is instead `\"host_terminated\"`: repo-contract did request that signal, just not via `options.signal` or `timeoutMs` (see `\"aborted\"`/`\"timed_out\"`), so it must not be conflated with an externally-caused `\"signaled\"`."
184
+ },
185
+ "ParsedOutput<unknown>": {
186
+ "anyOf": [
187
+ {
188
+ "$ref": "#/definitions/ParsedOutputSuccess%3Cunknown%3E"
189
+ },
190
+ {
191
+ "$ref": "#/definitions/ParsedOutputFailure"
192
+ }
193
+ ],
194
+ "description": "The result of a check's requested output-format parse: either a successful `ParsedOutputSuccess`, or a `ParsedOutputFailure`."
195
+ },
196
+ "ParsedOutputSuccess<unknown>": {
197
+ "type": "object",
198
+ "properties": {
199
+ "format": {
200
+ "$ref": "#/definitions/OutputFormat",
201
+ "description": "The format that was requested and successfully parsed."
202
+ },
203
+ "success": {
204
+ "type": "boolean",
205
+ "const": true,
206
+ "description": "Always `true`."
207
+ },
208
+ "value": {
209
+ "description": "The parsed value."
210
+ }
211
+ },
212
+ "required": [
213
+ "format",
214
+ "success",
215
+ "value"
216
+ ],
217
+ "description": "A requested parse of a check's stdout succeeded."
218
+ },
219
+ "OutputFormat": {
220
+ "type": "string",
221
+ "enum": [
222
+ "json",
223
+ "yaml",
224
+ "text"
225
+ ],
226
+ "description": "Output interpretation a check can explicitly request. No format requested means no parsing -- the consumer gets raw stdout/stderr only."
227
+ },
228
+ "ParsedOutputFailure": {
229
+ "type": "object",
230
+ "properties": {
231
+ "format": {
232
+ "$ref": "#/definitions/OutputFormat",
233
+ "description": "The format that was requested (and failed to parse)."
234
+ },
235
+ "success": {
236
+ "type": "boolean",
237
+ "const": false,
238
+ "description": "Always `false`."
239
+ },
240
+ "error": {
241
+ "type": "string",
242
+ "description": "The parse error's message."
243
+ }
244
+ },
245
+ "required": [
246
+ "format",
247
+ "success",
248
+ "error"
249
+ ],
250
+ "description": "A requested parse of a check's stdout failed. The raw stdout on the parent `CheckEvidence` is preserved unchanged -- a parse failure is never silently reinterpreted or discarded."
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://maverickcer.github.io/repo-contract/schema/verdict.schema.json",
4
+ "title": "repo-contract Verdict",
5
+ "description": "Machine-readable aggregate pass/fail result produced by evaluating repository-owned policies against an Evidence object -- one entry per configured check, each individually inspectable. Generated from src/types.ts's Verdict type -- never hand-authored.",
6
+ "$ref": "#/definitions/VerdictSchemaSource",
7
+ "definitions": {
8
+ "VerdictSchemaSource": {
9
+ "$ref": "#/definitions/Verdict"
10
+ },
11
+ "Verdict": {
12
+ "type": "object",
13
+ "properties": {
14
+ "version": {
15
+ "type": "number",
16
+ "const": 2,
17
+ "description": "Schema version of this shape; see VERSIONING.md."
18
+ },
19
+ "passed": {
20
+ "type": "boolean",
21
+ "description": "`true` only if every check's `outcome` is `\"pass\"` or `\"warn\"`."
22
+ },
23
+ "checks": {
24
+ "type": "object",
25
+ "additionalProperties": {
26
+ "$ref": "#/definitions/PolicyResult"
27
+ },
28
+ "description": "Each configured check's own `PolicyResult`, verbatim, keyed by check id."
29
+ }
30
+ },
31
+ "required": [
32
+ "version",
33
+ "passed",
34
+ "checks"
35
+ ],
36
+ "description": "Versioned, immutable aggregate result of evaluating every configured check's policy against its evidence -- each check's own `PolicyResult`, verbatim, keyed by check id. `passed` is `true` only if every check's `outcome` is `\"pass\"` or `\"warn\"` -- `\"fail\"` is the only outcome that fails the run; one failing check never collapses into a single generic message, every check remains individually inspectable under `checks`. Versioned independently of `Evidence` (see VERSIONING.md's schema-versioning policy) -- `version: 2` reflects `checks[id]` changing shape from `{ passed, reason? }` to a full `PolicyResult` (`{ outcome, rationale }`); see ADR 0001."
37
+ },
38
+ "PolicyResult": {
39
+ "type": "object",
40
+ "properties": {
41
+ "outcome": {
42
+ "$ref": "#/definitions/PolicyOutcome",
43
+ "description": "The policy's pass/fail/warn decision."
44
+ },
45
+ "rationale": {
46
+ "type": "string",
47
+ "description": "Why the policy reached `outcome`, in enough detail to act on without rerunning anything."
48
+ }
49
+ },
50
+ "required": [
51
+ "outcome",
52
+ "rationale"
53
+ ],
54
+ "description": "A repository-owned policy's interpretation of one check's captured evidence -- fully JSON-serializable (a plain object of primitives only, never an `Error`, class instance, function, or tool-specific object) so it can be persisted, transmitted, aggregated across parallel checks, and consumed directly by a human or an AI without rerunning anything.\n\n`rationale` is mandatory and must contain enough actionable detail -- specific file/line locations, rule ids, test names, counts -- for a consumer to understand *why* the policy reached its outcome from this value alone. A rationale like \"see output above\" or \"check the report for details\" defeats the purpose: it forces the consumer back to raw, unstructured command output, exactly what this type exists to avoid. See specs/architecture.md for the evidence/rationale/judgment distinction this type is built around: evidence answers \"what happened?\", `rationale` answers \"what does the repository's policy conclude about what happened?\", and a policy's `outcome` is not the final word -- a human or AI consumer still makes the final judgment call using both."
55
+ },
56
+ "PolicyOutcome": {
57
+ "type": "string",
58
+ "enum": [
59
+ "pass",
60
+ "fail",
61
+ "warn"
62
+ ],
63
+ "description": "`\"pass\"`: the repository-owned policy evaluated the captured evidence as satisfying its configured requirements. `\"fail\"`: the evidence did not satisfy them. `\"warn\"`: the evidence does not violate the policy's blocking requirements, but the policy has intentionally decided the condition is materially relevant and wants it surfaced -- not a synonym for \"minor failure\"; a `warn` never fails `Verdict.passed` (see `runPolicies` in `src/policy/run-policies.ts`)."
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,38 @@
1
+ import type { CheckSchema, RepoContractConfig, ValidatedCheckSchema } from "../types.js"
2
+
3
+ /**
4
+ * Identity function whose only job is type inference: authoring a config
5
+ * through `defineRepoContract` lets each check's `output` (present or
6
+ * absent, and which format) flow into that same check's `policy` parameter
7
+ * type, without the consumer writing any type annotations themselves. It
8
+ * also statically validates every check's `dependsOn` against its sibling
9
+ * check ids (see `ValidatedCheckSchema`) -- a typo'd or self-referencing id
10
+ * fails to compile here rather than only failing at runtime. Performs no
11
+ * other validation and no cloning -- `runRepoContract` validates whatever
12
+ * config it is ultimately given, whether or not it passed through this
13
+ * function first.
14
+ *
15
+ * `const TChecks` (TypeScript 5.0's `const` type parameter modifier) keeps
16
+ * each check's own configuration -- notably whether `output` is present at
17
+ * all -- from being widened during inference; without it, TypeScript's
18
+ * inference for a `Record` of heterogeneous generic entries does not
19
+ * reliably preserve that per-check shape once a callback property
20
+ * (`policy`) is also present. See `InferParsedValue` in types.ts for the
21
+ * related limitation this does not fully solve.
22
+ *
23
+ * `TChecks` is inferred from the plain, unwrapped `RepoContractConfig<TChecks>`
24
+ * position -- the `ValidatedCheckSchema<TChecks>` constraint on `checks` is
25
+ * intersected in afterward, computed from that already-inferred `TChecks`,
26
+ * rather than substituted in its place. Inferring `TChecks` directly from a
27
+ * mapped/conditional type over itself (as `ValidatedCheckSchema` is) loses
28
+ * the contextual typing every check's `policy` callback otherwise gets --
29
+ * another real, confirmed TypeScript inference limitation, distinct from
30
+ * the `output`-to-`policy` one above.
31
+ * @param config - the config to type-check and return unchanged.
32
+ * @returns the same `config` object, untouched and uncloned.
33
+ */
34
+ export function defineRepoContract<const TChecks extends CheckSchema>(
35
+ config: RepoContractConfig<TChecks> & { readonly checks: ValidatedCheckSchema<TChecks> },
36
+ ): RepoContractConfig<TChecks> {
37
+ return config
38
+ }
@@ -0,0 +1,214 @@
1
+ import { InvalidCheckConfigError } from "../errors.js"
2
+
3
+ /**
4
+ * Consumes a `\`-escape sequence starting at `run[i]`, if one applies here. A backslash escapes the
5
+ * next character (and is itself dropped) when unquoted or inside a double-quoted span; inside
6
+ * single quotes it is a literal character, exactly as in every POSIX shell -- so a Windows path in
7
+ * single quotes survives intact. A trailing backslash with nothing left to escape is not an escape
8
+ * and is kept literally by the caller.
9
+ * @param run - the full command string being tokenized.
10
+ * @param i - the index of the candidate backslash.
11
+ * @param quote - the current quote context (`"'"`, `'"'`, or `null` for unquoted).
12
+ * @returns the escaped character and the index just past the two-character sequence, or `undefined` when no escape applies at `i`.
13
+ */
14
+ function consumeEscape(
15
+ run: string,
16
+ i: number,
17
+ quote: "'" | '"' | null,
18
+ ): { readonly value: string; readonly next: number } | undefined {
19
+ if (run[i] !== "\\" || quote === "'") return undefined
20
+ // No explicit `i + 1 < run.length` pre-check: a trailing backslash at
21
+ // end-of-string reads `run[i + 1]` as `undefined` (which `noUncheckedIndexedAccess`
22
+ // already types), and the `escaped === undefined` guard below returns
23
+ // `undefined` for exactly that case -- so the caller keeps the backslash
24
+ // literal. One guard, one behavior, nothing to mutation-suppress.
25
+ const escaped: string | undefined = run[i + 1]
26
+ if (escaped === undefined) return undefined
27
+ return { value: escaped, next: i + 2 }
28
+ }
29
+
30
+ // Single-character shell/multi-command operators rejected outright when they
31
+ // appear unquoted. A literal newline (`\n`/`\r`) and the two-character `$(`
32
+ // are handled separately in `rejectUnquotedOperator` -- every entry here is
33
+ // exactly one character, matched by a single Set lookup rather than a chain
34
+ // of per-character `if`s.
35
+ const UNQUOTED_SHELL_OPERATORS: ReadonlySet<string> = new Set([";", "&", "|", "`", "<", ">"])
36
+
37
+ /**
38
+ * Throws `InvalidCheckConfigError` when the unquoted character `char` at `run[i]` is a
39
+ * shell/multi-command operator repo-contract never interprets (`;`, `&`, `|`, a backtick, `<`, `>`,
40
+ * `$(`, or a literal newline). Returns normally when `char` is legitimate literal argv content --
41
+ * glob characters and a bare `$` deliberately included (see `tokenizeRunString`'s doc comment).
42
+ * @param char - the character under the cursor, already known defined by the caller.
43
+ * @param run - the full command string, needed only to look one character ahead for `$(`.
44
+ * @param i - the index of `char` within `run`.
45
+ * @param checkId - identifies which check's `run` was invalid, used in the thrown error message.
46
+ */
47
+ function rejectUnquotedOperator(char: string, run: string, i: number, checkId: string): void {
48
+ const reject = (operator: string): never => {
49
+ throw new InvalidCheckConfigError(
50
+ checkId,
51
+ `run string contains an unquoted "${operator}" -- repo-contract never invokes a shell for ` +
52
+ `string-form "run", so shell operators are not interpreted. Use "run: [...]" (array ` +
53
+ `form) to pass "${operator}" as a literal argument, or set "shell: true" to opt into ` +
54
+ `real shell execution.`,
55
+ )
56
+ }
57
+
58
+ if (char === "\n" || char === "\r") reject("newline")
59
+ if (UNQUOTED_SHELL_OPERATORS.has(char)) reject(char)
60
+ if (char === "$" && run[i + 1] === "(") reject("$(")
61
+ }
62
+
63
+ /**
64
+ * Splits a `run` string into argv (executable + arguments) without invoking
65
+ * a shell -- no shell operator is ever executed, no glob is ever expanded by
66
+ * this package, no environment variable is ever substituted. The result is
67
+ * deterministic: the same input string always produces the same argv array.
68
+ *
69
+ * Quoting: `'...'` and `"..."` group whitespace into a single argument and
70
+ * are themselves stripped from the resulting token. `\` escapes the next
71
+ * character (and is itself stripped) when unquoted or inside a double-quoted
72
+ * span; inside single quotes it is a literal character, exactly as in every
73
+ * POSIX shell -- so a Windows path in single quotes survives intact.
74
+ * Unquoted whitespace (space, tab) separates tokens.
75
+ *
76
+ * Rejected outright (throws `InvalidCheckConfigError`, checkId identifies
77
+ * which check's `run` was invalid): any *unquoted* occurrence of a true
78
+ * shell/multi-command operator -- `;`, `&`, `|`, a backtick, `$(`, `<`, `>`,
79
+ * or a literal newline. A string containing one of these almost always
80
+ * reflects a mistaken assumption that shell interpretation is happening;
81
+ * the fix is either `run: [...]` (array form, bypasses tokenization
82
+ * entirely) or explicit `shell: true`.
83
+ *
84
+ * Deliberately NOT rejected: glob characters (`*`, `?`, `~`, `[`, `]`, `{`,
85
+ * `}`) and a bare `$`. These are common, legitimate literal argv content --
86
+ * many CLI tools (eslint, prettier, tsc) accept and internally expand glob
87
+ * patterns themselves, e.g. `eslint "src/**\/*.ts"` -- and since no shell is
88
+ * ever invoked here, they carry zero shell-injection risk regardless of
89
+ * where they appear in the string.
90
+ * @param run - the command string to tokenize.
91
+ * @param checkId - identifies which check's `run` was invalid, used in the thrown error message.
92
+ * @returns the tokenized argv (executable followed by its arguments).
93
+ */
94
+ export function tokenizeRunString(run: string, checkId: string): readonly string[] {
95
+ const tokens: string[] = []
96
+ let current = ""
97
+ let hasCurrent = false
98
+ let quote: "'" | '"' | null = null
99
+ let i = 0
100
+
101
+ // Loosening the `i < run.length` bound to `i <= run.length` is
102
+ // behaviorally invisible: the one extra iteration it would permit reads
103
+ // `run[run.length]`, which is `undefined`, and is caught immediately below
104
+ // by the (itself unmutatable, for the same `noUncheckedIndexedAccess`
105
+ // reason) `char === undefined` check -- confirmed equivalent by exhaustive
106
+ // differential testing against a wide corpus of inputs, not assumed.
107
+ //
108
+ // `iterations` is a second, independent forward-progress bound: every loop
109
+ // pass that doesn't throw/break advances `i` by exactly 1 or 2, so no
110
+ // correct execution ever needs more than `run.length` passes -- a
111
+ // regression that makes `i` stand still or move backward (a `+=`
112
+ // accidentally becoming `-=`), or that wipes the loop body entirely,
113
+ // would otherwise hang forever instead of failing loudly. It is
114
+ // deliberately tracked in the `for` statement's own update/condition
115
+ // clauses rather than inside the loop body: those clauses sit outside the
116
+ // body's own `{ ... }` block, so they keep running (and keep bounding the
117
+ // loop) even under a mutation that replaces the entire body with `{}`,
118
+ // which a bound placed inside the body could not survive.
119
+ //
120
+ // Every mutation of this line's own clauses (loosening either half of the
121
+ // `&&`, swapping it for `||`, or reversing `iterations`' own direction) is
122
+ // itself equivalent as long as the *body* still advances `i` correctly:
123
+ // `i < run.length` alone already terminates the loop at the right point
124
+ // for correct code, with the `iterations` bound only ever mattering in
125
+ // combination with a genuine body regression -- confirmed empirically:
126
+ // mutating this line in isolation (leaving the body untouched) produces no
127
+ // observable difference. It exists precisely to convert the *body*
128
+ // mutations described above from an unkillable hang into a fast, visible
129
+ // test failure, not to be independently killable itself.
130
+ // Stryker disable next-line ConditionalExpression,EqualityOperator,LogicalOperator,AssignmentOperator,BlockStatement -- loosening i < run.length to i <= run.length is behaviorally invisible since the unmutatable char === undefined check right after already catches it, and the iterations bound is a second, independent forward-progress bound tracked in this line's own clauses (outside the body's braces) specifically so a body-emptying mutation can't produce an unkillable hang; every mutation of this line's own clauses is equivalent as long as the body still advances i correctly, confirmed empirically.
131
+ for (let iterations = 0; i < run.length && iterations <= run.length; iterations += 1) {
132
+ const char = run[i]
133
+ // Unreachable given the loop condition (`i < run.length` already
134
+ // guarantees `run[i]` is defined) -- kept only because
135
+ // `noUncheckedIndexedAccess` can't itself express that invariant.
136
+ // Stryker disable next-line ConditionalExpression -- unreachable given the loop's own i < run.length guard already ensures run[i] is defined; kept only because noUncheckedIndexedAccess can't itself express that invariant.
137
+ if (char === undefined) break
138
+
139
+ if (quote !== null) {
140
+ const escape = consumeEscape(run, i, quote)
141
+ if (escape !== undefined) {
142
+ current += escape.value
143
+ i = escape.next
144
+ continue
145
+ }
146
+ if (char === quote) {
147
+ quote = null
148
+ i += 1
149
+ continue
150
+ }
151
+ current += char
152
+ i += 1
153
+ continue
154
+ }
155
+
156
+ if (char === "'" || char === '"') {
157
+ quote = char
158
+ hasCurrent = true
159
+ i += 1
160
+ continue
161
+ }
162
+
163
+ // An unquoted backslash before a newline is a shell line-continuation.
164
+ // repo-contract never interprets one -- and it must be rejected *here*,
165
+ // before `consumeEscape` below, because `consumeEscape` would otherwise
166
+ // treat `\<newline>` as an ordinary escape: splice a literal newline into
167
+ // the token and advance the cursor past the newline, so the bare-newline
168
+ // rejection further down never runs.
169
+ const nextChar = run[i + 1]
170
+ if (char === "\\" && (nextChar === "\n" || nextChar === "\r")) {
171
+ throw new InvalidCheckConfigError(
172
+ checkId,
173
+ `run string contains an unquoted line continuation (a backslash before a newline) -- ` +
174
+ `repo-contract never invokes a shell for string-form "run". Use "run: [...]" (array ` +
175
+ `form), or set "shell: true" to opt into real shell execution.`,
176
+ )
177
+ }
178
+
179
+ const escape = consumeEscape(run, i, null)
180
+ if (escape !== undefined) {
181
+ current += escape.value
182
+ hasCurrent = true
183
+ i = escape.next
184
+ continue
185
+ }
186
+
187
+ if (char === " " || char === "\t") {
188
+ if (hasCurrent) {
189
+ tokens.push(current)
190
+ current = ""
191
+ hasCurrent = false
192
+ }
193
+ i += 1
194
+ continue
195
+ }
196
+
197
+ rejectUnquotedOperator(char, run, i, checkId)
198
+
199
+ current += char
200
+ hasCurrent = true
201
+ i += 1
202
+ }
203
+
204
+ if (quote !== null) {
205
+ throw new InvalidCheckConfigError(checkId, `run string has an unterminated ${quote} quote.`)
206
+ }
207
+ if (hasCurrent) tokens.push(current)
208
+
209
+ if (tokens.length === 0) {
210
+ throw new InvalidCheckConfigError(checkId, "run string is empty or contains only whitespace.")
211
+ }
212
+
213
+ return tokens
214
+ }