@kici-dev/engine 0.4.0 → 0.6.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 (81) hide show
  1. package/dist/audit/access-log-policy.js +1 -0
  2. package/dist/audit/retention-policy.js +2 -0
  3. package/dist/context/held-run-job-id.d.ts +26 -10
  4. package/dist/context/held-run-job-id.js +30 -11
  5. package/dist/context/host-match.js +2 -1
  6. package/dist/context/index.d.ts +1 -1
  7. package/dist/context/index.js +3 -3
  8. package/dist/context/types.d.ts +10 -1
  9. package/dist/context/types.js +10 -1
  10. package/dist/index.d.ts +6 -2
  11. package/dist/index.js +17 -12
  12. package/dist/labels/compile.d.ts +2 -7
  13. package/dist/labels/compile.js +1 -10
  14. package/dist/labels.d.ts +74 -12
  15. package/dist/labels.js +82 -17
  16. package/dist/mcp/held-run-resolve.d.ts +40 -0
  17. package/dist/mcp/held-run-resolve.js +68 -15
  18. package/dist/mcp/tool-schemas.d.ts +4 -0
  19. package/dist/mcp/tool-schemas.js +13 -1
  20. package/dist/metrics/catalog-policy.d.ts +17 -3
  21. package/dist/metrics/catalog-policy.js +44 -13
  22. package/dist/metrics/metric-catalog.generated.d.ts +140 -0
  23. package/dist/metrics/metric-catalog.generated.js +168 -0
  24. package/dist/protocol/dashboard-global-workflows.js +2 -2
  25. package/dist/protocol/event-log-payload.js +1 -1
  26. package/dist/protocol/messages/access-log.d.ts +5 -0
  27. package/dist/protocol/messages/access-log.js +1 -0
  28. package/dist/protocol/messages/actor.d.ts +13 -2
  29. package/dist/protocol/messages/actor.js +16 -5
  30. package/dist/protocol/messages/common.js +1 -1
  31. package/dist/protocol/messages/dashboard-global-workflows.d.ts +29 -3
  32. package/dist/protocol/messages/dashboard-global-workflows.js +48 -4
  33. package/dist/protocol/messages/dashboard.d.ts +48 -7
  34. package/dist/protocol/messages/dashboard.js +62 -6
  35. package/dist/protocol/messages/execution-status.d.ts +44 -0
  36. package/dist/protocol/messages/execution-status.js +69 -2
  37. package/dist/protocol/messages/git-credential-relay.d.ts +78 -0
  38. package/dist/protocol/messages/git-credential-relay.js +86 -0
  39. package/dist/protocol/messages/orchestrator-agent.d.ts +138 -0
  40. package/dist/protocol/messages/orchestrator-agent.js +131 -3
  41. package/dist/protocol/messages/peer.d.ts +7 -0
  42. package/dist/protocol/messages/peer.js +18 -1
  43. package/dist/protocol/messages/platform-orchestrator.d.ts +155 -2
  44. package/dist/protocol/messages/platform-orchestrator.js +178 -19
  45. package/dist/protocol/version.d.ts +19 -2
  46. package/dist/protocol/version.js +20 -3
  47. package/dist/provenance/verify.js +11 -10
  48. package/dist/provider/check-status-poster.d.ts +37 -2
  49. package/dist/provider/contributor-resolver.d.ts +11 -3
  50. package/dist/provider/file-contents-fetcher.d.ts +39 -0
  51. package/dist/provider/file-contents-fetcher.js +2 -0
  52. package/dist/provider/git-credential.d.ts +77 -0
  53. package/dist/provider/git-credential.js +10 -0
  54. package/dist/provider/index.d.ts +4 -0
  55. package/dist/provider/index.js +2 -1
  56. package/dist/provider/webhook-normalizer.d.ts +12 -12
  57. package/dist/repo/pattern-negation.d.ts +73 -0
  58. package/dist/repo/pattern-negation.js +86 -0
  59. package/dist/safe-regex.d.ts +16 -0
  60. package/dist/safe-regex.js +24 -0
  61. package/dist/scaler/registry-auth.d.ts +18 -0
  62. package/dist/scaler/registry-auth.js +28 -0
  63. package/dist/scaler/scaler-backend-type.d.ts +35 -0
  64. package/dist/scaler/scaler-backend-type.js +39 -2
  65. package/dist/scaler/scaler-events.d.ts +79 -0
  66. package/dist/scaler/scaler-events.js +87 -0
  67. package/dist/trigger/compiled-matchers.d.ts +21 -1
  68. package/dist/trigger/compiled-matchers.js +30 -3
  69. package/dist/trigger/content-requirements.d.ts +31 -0
  70. package/dist/trigger/content-requirements.js +125 -0
  71. package/dist/trigger/decision-trace.d.ts +168 -0
  72. package/dist/trigger/decision-trace.js +207 -4
  73. package/dist/trigger/jsonpath-matcher.js +5 -1
  74. package/dist/trigger/matcher.js +61 -10
  75. package/dist/trigger/text-match.d.ts +27 -0
  76. package/dist/trigger/text-match.js +86 -0
  77. package/dist/trigger/types.d.ts +265 -23
  78. package/dist/trigger/types.js +37 -5
  79. package/dist/ws/rate-limiter.js +3 -3
  80. package/package.json +20 -3
  81. package/sbom.spdx.json +40 -15
@@ -0,0 +1,125 @@
1
+ import "../rolldown-runtime-ClRpJifh.js";
2
+ import { resolveContentFormat } from "./types.js";
3
+ import { evaluateTextMatch } from "./text-match.js";
4
+ import { matchJsonPath, matchJsonPathNot } from "./jsonpath-matcher.js";
5
+ import { JSONPath } from "jsonpath-plus";
6
+ import { parse } from "yaml";
7
+ //#region src/trigger/content-requirements.ts
8
+ /**
9
+ * Declarative static content-filter matcher.
10
+ *
11
+ * A git-event trigger may carry a `requires` list ({@link LockContentRequirement}):
12
+ * pure DATA describing a query over the bytes of one source file at the event's
13
+ * ref. The orchestrator interprets that data here — it never executes author
14
+ * code — which is what keeps content filtering inside the orchestrator under the
15
+ * execution-purity model.
16
+ *
17
+ * This module imports `yaml`, so it is a Node-safe subpath export
18
+ * (`@kici-dev/engine/trigger/content-requirements`) and is deliberately kept out
19
+ * of the browser-facing engine barrel (`src/index.ts`).
20
+ */
21
+ /** Hard byte cap enforced before any parse: an oversize file is indeterminate. */
22
+ const MAX_CONTENT_BYTES = 1048576;
23
+ /**
24
+ * Anchor/alias expansion cap for YAML parsing. The `yaml` library's default of
25
+ * 100 does not reject a small billion-laughs bomb; 50 rejects it while staying
26
+ * generous for legitimately-anchored configs (merge keys, shared defaults). The
27
+ * 1 MiB byte cap above bounds total node count as the complementary limit.
28
+ */
29
+ const YAML_MAX_ALIAS_COUNT = 50;
30
+ /**
31
+ * Parse `bytes` for the given concrete format. `text` returns the raw string;
32
+ * `json` uses `JSON.parse`; `yaml` uses a hardened `yaml.parse` with an explicit
33
+ * anchor/alias cap. Throws on a malformed document (the caller treats a throw as
34
+ * indeterminate).
35
+ */
36
+ function parseForFormat(bytes, format) {
37
+ switch (format) {
38
+ case "json": return JSON.parse(bytes);
39
+ case "yaml": return parse(bytes, { maxAliasCount: YAML_MAX_ALIAS_COUNT });
40
+ case "text": return bytes;
41
+ }
42
+ }
43
+ /** True when the JSONPath resolves to ≥1 node in the parsed document. */
44
+ function pathExists(doc, path) {
45
+ return JSONPath({
46
+ path,
47
+ json: doc,
48
+ wrap: true
49
+ }).length > 0;
50
+ }
51
+ /** JSONPath queries need an object root; wrap a non-object parsed doc so lookups stay safe. */
52
+ function asJsonRoot(doc) {
53
+ return typeof doc === "object" && doc !== null ? doc : {};
54
+ }
55
+ /**
56
+ * Evaluate one content requirement against the resolved file map.
57
+ * Returns a per-entry {@link ContentRequirementResult}. `pass:false` with no
58
+ * `indeterminate` is a definite negative; `indeterminate` means the file could
59
+ * not be evaluated (oversize, parse failure, unsafe regex) and is fail-visible.
60
+ */
61
+ function evaluateOne(req, entry) {
62
+ const present = entry?.present === true;
63
+ if (req.absent) return { pass: !present };
64
+ if (!present) return { pass: false };
65
+ const bytes = entry?.bytes ?? "";
66
+ if (Buffer.byteLength(bytes, "utf8") > MAX_CONTENT_BYTES) return {
67
+ pass: false,
68
+ indeterminate: `${req.file}: exceeds 1 MiB size cap`
69
+ };
70
+ const textResult = evaluateTextMatch(bytes, {
71
+ ...req.contains !== void 0 && { contains: req.contains },
72
+ ...req.notContains !== void 0 && { notContains: req.notContains },
73
+ ...req.matches !== void 0 && { matches: req.matches },
74
+ ...req.notMatches !== void 0 && { notMatches: req.notMatches },
75
+ ...req.ignoreCase !== void 0 && { ignoreCase: req.ignoreCase }
76
+ });
77
+ if (textResult.indeterminate) return {
78
+ pass: false,
79
+ indeterminate: `${req.file}: ${textResult.indeterminate}`
80
+ };
81
+ if (!textResult.pass) return { pass: false };
82
+ if (req.exists && req.exists.length > 0 || req.match && Object.keys(req.match).length > 0 || req.not && Object.keys(req.not).length > 0) {
83
+ const format = resolveContentFormat(req.file, req.format);
84
+ let parsed;
85
+ try {
86
+ parsed = parseForFormat(bytes, format);
87
+ } catch (err) {
88
+ const reason = err instanceof Error ? err.message : String(err);
89
+ return {
90
+ pass: false,
91
+ indeterminate: `${req.file}: failed to parse as ${format}: ${reason}`
92
+ };
93
+ }
94
+ const root = asJsonRoot(parsed);
95
+ if (req.exists) {
96
+ for (const path of req.exists) if (!pathExists(parsed, path)) return { pass: false };
97
+ }
98
+ if (req.match && !matchJsonPath(root, req.match)) return { pass: false };
99
+ if (req.not && !matchJsonPathNot(root, req.not)) return { pass: false };
100
+ }
101
+ return { pass: true };
102
+ }
103
+ /**
104
+ * Evaluate an AND-ed list of content requirements against a resolved file map.
105
+ *
106
+ * Every entry must pass for the overall result to pass; an empty list passes.
107
+ * The first indeterminate entry short-circuits and is surfaced (fail-visible):
108
+ * an unevaluable file NEVER passes silently.
109
+ *
110
+ * @param reqs The lock `requires` list (each entry AND-ed).
111
+ * @param files Resolved file contents keyed by repo-relative path. A missing key
112
+ * or `{ present: false }` means the file does not exist at the ref.
113
+ */
114
+ function evaluateContentRequirements(reqs, files) {
115
+ for (const req of reqs) {
116
+ const result = evaluateOne(req, files.get(req.file));
117
+ if (result.indeterminate) return result;
118
+ if (!result.pass) return { pass: false };
119
+ }
120
+ return { pass: true };
121
+ }
122
+ //#endregion
123
+ export { evaluateContentRequirements, parseForFormat };
124
+
125
+ //# sourceMappingURL=content-requirements.js.map
@@ -2,6 +2,7 @@
2
2
  * Decision trace recording for debugging trigger matching.
3
3
  * Records every check performed during trigger evaluation.
4
4
  */
5
+ import type { LockTextMatch } from './types.js';
5
6
  /**
6
7
  * Individual trace entry for a single check.
7
8
  */
@@ -30,10 +31,177 @@ export interface WorkflowDecision {
30
31
  /** Summary reason */
31
32
  summary: string;
32
33
  }
34
+ /**
35
+ * Max characters of any free-text field a trace entry carries, so an
36
+ * essay-length input stays bounded.
37
+ *
38
+ * The entry count is capped separately, downstream, but a count bound alone
39
+ * bounds nothing: a single `paths` entry names every changed file in the push
40
+ * and a single `bodyMatch` entry quotes a comment body an outsider authored, so
41
+ * fifty entries can still be megabytes. The forwarded trace rides one WebSocket
42
+ * frame to the Platform, and a frame past the server's payload ceiling closes
43
+ * the connection — stalling every delivery for that organization until it
44
+ * reconnects. Bounding at the point each field is minted is what makes the
45
+ * downstream size guards a backstop rather than the only limit.
46
+ */
47
+ export declare const TRACE_TEXT_MAX = 200;
48
+ /** Clamp one free-text trace field, marking a clamped value with an ellipsis. */
49
+ export declare function truncateTraceText(text: string): string;
33
50
  /**
34
51
  * Create a new trace entry.
52
+ *
53
+ * Every free-text field is clamped here rather than at each call site: the
54
+ * fields are fed from event content of unbounded size, and one unclamped call
55
+ * site is enough to reintroduce an unbounded frame.
35
56
  */
36
57
  export declare function createTraceEntry(check: string, pattern: string, value: string, passed: boolean, reason?: string): TraceEntry;
58
+ /**
59
+ * Stable `check` labels for the gates an organization-wide global workflow
60
+ * passes on its way to dispatch.
61
+ *
62
+ * The other trigger checks are minted with free-text labels because each one
63
+ * names the trigger field it read. These are named constants instead: they are
64
+ * recorded in one package and read back in another — by code, and by whoever is
65
+ * asking "why did nothing run?" — so a typo on either side would silently
66
+ * produce a trace nobody can search for.
67
+ */
68
+ export declare const TraceCheck: {
69
+ /** `repos` glob/regex filter, deciding whether a workflow applies to the event's repo. */
70
+ readonly RepoFilter: 'repo';
71
+ /** Tier-1 declarative `requires` content filter, interpreted by the orchestrator. */
72
+ readonly ContentRequirements: 'requires';
73
+ /** Tier-2 `filter` predicate, run by an agent in the global eval round. */
74
+ readonly GlobalFilter: 'filter';
75
+ /** Tier-0 declarative `commitMessage` filter, read from the normalized event. */
76
+ readonly CommitMessage: 'commitMessage';
77
+ /** Materialization of a matched workflow's jobs, on the way to dispatch. */
78
+ readonly Dispatch: 'dispatch';
79
+ };
80
+ export type TraceCheck = (typeof TraceCheck)[keyof typeof TraceCheck];
81
+ /**
82
+ * Verdict vocabulary shared by both gates.
83
+ *
84
+ * `Indeterminate` is deliberately distinct from `Excluded`: a gate that could
85
+ * not be evaluated did not decide anything, and reporting it as an exclusion
86
+ * would tell a workflow author their filter said no when nothing ever ran it.
87
+ */
88
+ export declare const TraceVerdict: {
89
+ readonly Matched: 'matched';
90
+ readonly Excluded: 'excluded';
91
+ readonly Indeterminate: 'indeterminate';
92
+ };
93
+ export type TraceVerdict = (typeof TraceVerdict)[keyof typeof TraceVerdict];
94
+ /**
95
+ * Record the Tier-1 `requires` content filter's verdict for one workflow.
96
+ *
97
+ * `files` is the set of repo-relative paths the requirement list reads, so the
98
+ * entry names what was inspected as well as what it concluded.
99
+ */
100
+ export declare function createContentRequirementsTraceEntry(args: {
101
+ files: readonly string[];
102
+ passed: boolean;
103
+ /** Set when the requirement list could not be evaluated (unreadable file, bad parse). */
104
+ indeterminate?: boolean;
105
+ reason?: string;
106
+ }): TraceEntry;
107
+ /**
108
+ * Record the Tier-2 `filter` predicate's verdict for one workflow.
109
+ *
110
+ * Without this entry a `filter` exclusion is invisible: the predicate runs on an
111
+ * agent, returns `false`, and the workflow simply never appears — leaving its
112
+ * author nothing to inspect. The entry is the answer to "why did nothing run?".
113
+ */
114
+ export declare function createGlobalFilterTraceEntry(args: {
115
+ /** The round's verdict for this candidate: `true` means the filter admitted it. */
116
+ run: boolean;
117
+ /** True when the round could not decide (a failed round, a budget breach). */
118
+ indeterminate?: boolean;
119
+ reason?: string;
120
+ }): TraceEntry;
121
+ /**
122
+ * Record that a matched workflow could not be materialized into jobs.
123
+ *
124
+ * A workflow whose triggers matched and whose build then threw is absent from
125
+ * every other record: no run row is created and no job is queued. Omitting it
126
+ * from the trace as well leaves its author unable to tell it apart from a
127
+ * workflow that was never registered — the exact indistinguishability the trace
128
+ * exists to remove.
129
+ */
130
+ export declare function createDispatchFailureTraceEntry(reason: string): TraceEntry;
131
+ /**
132
+ * Return a copy of `decision` with `entries` appended to its checks.
133
+ *
134
+ * A decision is treated as a value, never mutated in place: the same object is
135
+ * read by the caller that recorded it, and a gate appending to it would
136
+ * retroactively rewrite what an earlier reader saw.
137
+ *
138
+ * A failing appended entry demotes `matched` and replaces `summary`, because a
139
+ * workflow whose triggers matched but whose content filter excluded it did NOT
140
+ * match overall — reporting it as matched is precisely the invisible outcome
141
+ * these entries exist to explain.
142
+ */
143
+ export declare function appendChecks(decision: WorkflowDecision, entries: readonly TraceEntry[]): WorkflowDecision;
144
+ /**
145
+ * Record the Tier-0 `commitMessage` filter's verdict for one trigger.
146
+ *
147
+ * `text: undefined` means the event carried no message — an INDETERMINATE
148
+ * verdict, deliberately distinct from an exclusion: reporting it as "excluded"
149
+ * would tell an author their filter said no when nothing ever read it.
150
+ */
151
+ export declare function createCommitMessageTraceEntry(args: {
152
+ match: LockTextMatch;
153
+ text: string | undefined;
154
+ passed: boolean;
155
+ indeterminate?: boolean;
156
+ reason?: string;
157
+ }): TraceEntry;
158
+ /**
159
+ * What a withheld trace field is replaced with when the reader does not hold
160
+ * `event_log:read_payload`.
161
+ *
162
+ * Lives here rather than beside the Platform's redactor because three packages
163
+ * read it: the Platform writes it, the dashboard renders it, and the E2E suite
164
+ * asserts the permission boundary against it. A literal repeated at each site
165
+ * would drift into a marker one of them no longer recognizes.
166
+ */
167
+ export declare const REDACTED_TRACE_FIELD = "[redacted \u2014 requires event_log:read_payload]";
168
+ /**
169
+ * Workflow name the truncation marker carries.
170
+ *
171
+ * A reserved sentinel rather than a real workflow: it is written by one package
172
+ * and read back by two others, so a literal repeated at each site would drift
173
+ * into a marker nobody recognizes.
174
+ */
175
+ export declare const TRACE_TRUNCATION_WORKFLOW_NAME = "(trace truncated)";
176
+ /**
177
+ * Build the marker that stands in for the decisions a size budget dropped.
178
+ *
179
+ * The trace is truncated rather than discarded: a reader who is told nothing
180
+ * cannot tell "matching never ran" from "the trace was too large to keep", and
181
+ * those two have opposite answers to "why did my workflow not fire".
182
+ */
183
+ export declare function createTraceTruncationMarker(omitted: number): Record<string, unknown>;
184
+ /**
185
+ * UTF-8 byte length of `text`.
186
+ *
187
+ * Byte length, never `String.length`: the latter counts UTF-16 code units, so a
188
+ * CJK-heavy comment body measures at roughly a third of the bytes it actually
189
+ * costs on the wire and in the stored row.
190
+ */
191
+ export declare function utf8ByteLength(text: string): number;
192
+ /**
193
+ * Drop trailing decisions until the serialized array fits `maxBytes`, appending
194
+ * a marker naming how many were dropped.
195
+ *
196
+ * Shared by the orchestrator, which bounds what it puts on the wire, and the
197
+ * Platform, which bounds what it stores. Two independent budgets over one
198
+ * shape: keeping one implementation is what stops them from disagreeing about
199
+ * what a truncated trace looks like.
200
+ */
201
+ export declare function truncateDecisionsToByteBudget<T>(decisions: readonly T[], maxBytes: number): {
202
+ decisions: Array<T | Record<string, unknown>>;
203
+ omitted: number;
204
+ };
37
205
  /**
38
206
  * Create a workflow decision record.
39
207
  */
@@ -1,15 +1,218 @@
1
1
  import "../rolldown-runtime-ClRpJifh.js";
2
+ import { describeTextMatch } from "./text-match.js";
2
3
  //#region src/trigger/decision-trace.ts
3
4
  /**
5
+ * Decision trace recording for debugging trigger matching.
6
+ * Records every check performed during trigger evaluation.
7
+ */
8
+ /**
9
+ * Max characters of any free-text field a trace entry carries, so an
10
+ * essay-length input stays bounded.
11
+ *
12
+ * The entry count is capped separately, downstream, but a count bound alone
13
+ * bounds nothing: a single `paths` entry names every changed file in the push
14
+ * and a single `bodyMatch` entry quotes a comment body an outsider authored, so
15
+ * fifty entries can still be megabytes. The forwarded trace rides one WebSocket
16
+ * frame to the Platform, and a frame past the server's payload ceiling closes
17
+ * the connection — stalling every delivery for that organization until it
18
+ * reconnects. Bounding at the point each field is minted is what makes the
19
+ * downstream size guards a backstop rather than the only limit.
20
+ */
21
+ const TRACE_TEXT_MAX = 200;
22
+ /** Clamp one free-text trace field, marking a clamped value with an ellipsis. */
23
+ function truncateTraceText(text) {
24
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
25
+ }
26
+ /**
4
27
  * Create a new trace entry.
28
+ *
29
+ * Every free-text field is clamped here rather than at each call site: the
30
+ * fields are fed from event content of unbounded size, and one unclamped call
31
+ * site is enough to reintroduce an unbounded frame.
5
32
  */
6
33
  function createTraceEntry(check, pattern, value, passed, reason) {
7
34
  return {
8
35
  check,
9
- pattern,
10
- value,
36
+ pattern: truncateTraceText(pattern),
37
+ value: truncateTraceText(value),
11
38
  passed,
12
- reason
39
+ reason: reason === void 0 ? void 0 : truncateTraceText(reason)
40
+ };
41
+ }
42
+ /**
43
+ * Stable `check` labels for the gates an organization-wide global workflow
44
+ * passes on its way to dispatch.
45
+ *
46
+ * The other trigger checks are minted with free-text labels because each one
47
+ * names the trigger field it read. These are named constants instead: they are
48
+ * recorded in one package and read back in another — by code, and by whoever is
49
+ * asking "why did nothing run?" — so a typo on either side would silently
50
+ * produce a trace nobody can search for.
51
+ */
52
+ const TraceCheck = {
53
+ /** `repos` glob/regex filter, deciding whether a workflow applies to the event's repo. */
54
+ RepoFilter: "repo",
55
+ /** Tier-1 declarative `requires` content filter, interpreted by the orchestrator. */
56
+ ContentRequirements: "requires",
57
+ /** Tier-2 `filter` predicate, run by an agent in the global eval round. */
58
+ GlobalFilter: "filter",
59
+ /** Tier-0 declarative `commitMessage` filter, read from the normalized event. */
60
+ CommitMessage: "commitMessage",
61
+ /** Materialization of a matched workflow's jobs, on the way to dispatch. */
62
+ Dispatch: "dispatch"
63
+ };
64
+ /**
65
+ * Verdict vocabulary shared by both gates.
66
+ *
67
+ * `Indeterminate` is deliberately distinct from `Excluded`: a gate that could
68
+ * not be evaluated did not decide anything, and reporting it as an exclusion
69
+ * would tell a workflow author their filter said no when nothing ever ran it.
70
+ */
71
+ const TraceVerdict = {
72
+ Matched: "matched",
73
+ Excluded: "excluded",
74
+ Indeterminate: "indeterminate"
75
+ };
76
+ /** Pick the verdict for a gate from its pass flag and whether it could decide. */
77
+ function verdictFor(passed, indeterminate) {
78
+ if (indeterminate) return TraceVerdict.Indeterminate;
79
+ return passed ? TraceVerdict.Matched : TraceVerdict.Excluded;
80
+ }
81
+ /**
82
+ * Record the Tier-1 `requires` content filter's verdict for one workflow.
83
+ *
84
+ * `files` is the set of repo-relative paths the requirement list reads, so the
85
+ * entry names what was inspected as well as what it concluded.
86
+ */
87
+ function createContentRequirementsTraceEntry(args) {
88
+ return createTraceEntry(TraceCheck.ContentRequirements, args.files.length > 0 ? args.files.join(", ") : "(no files)", verdictFor(args.passed, args.indeterminate === true), args.passed, args.reason);
89
+ }
90
+ /**
91
+ * Record the Tier-2 `filter` predicate's verdict for one workflow.
92
+ *
93
+ * Without this entry a `filter` exclusion is invisible: the predicate runs on an
94
+ * agent, returns `false`, and the workflow simply never appears — leaving its
95
+ * author nothing to inspect. The entry is the answer to "why did nothing run?".
96
+ */
97
+ function createGlobalFilterTraceEntry(args) {
98
+ return createTraceEntry(TraceCheck.GlobalFilter, "filter(context) === true", verdictFor(args.run, args.indeterminate === true), args.run, args.reason);
99
+ }
100
+ /**
101
+ * Record that a matched workflow could not be materialized into jobs.
102
+ *
103
+ * A workflow whose triggers matched and whose build then threw is absent from
104
+ * every other record: no run row is created and no job is queued. Omitting it
105
+ * from the trace as well leaves its author unable to tell it apart from a
106
+ * workflow that was never registered — the exact indistinguishability the trace
107
+ * exists to remove.
108
+ */
109
+ function createDispatchFailureTraceEntry(reason) {
110
+ return createTraceEntry(TraceCheck.Dispatch, "jobs materialize", TraceVerdict.Excluded, false, reason);
111
+ }
112
+ /**
113
+ * Return a copy of `decision` with `entries` appended to its checks.
114
+ *
115
+ * A decision is treated as a value, never mutated in place: the same object is
116
+ * read by the caller that recorded it, and a gate appending to it would
117
+ * retroactively rewrite what an earlier reader saw.
118
+ *
119
+ * A failing appended entry demotes `matched` and replaces `summary`, because a
120
+ * workflow whose triggers matched but whose content filter excluded it did NOT
121
+ * match overall — reporting it as matched is precisely the invisible outcome
122
+ * these entries exist to explain.
123
+ */
124
+ function appendChecks(decision, entries) {
125
+ const failed = entries.find((entry) => !entry.passed);
126
+ return {
127
+ ...decision,
128
+ matched: decision.matched && failed === void 0,
129
+ checks: [...decision.checks, ...entries],
130
+ summary: failed ? failed.reason ?? `Excluded by the ${failed.check} check` : decision.summary
131
+ };
132
+ }
133
+ /**
134
+ * Record the Tier-0 `commitMessage` filter's verdict for one trigger.
135
+ *
136
+ * `text: undefined` means the event carried no message — an INDETERMINATE
137
+ * verdict, deliberately distinct from an exclusion: reporting it as "excluded"
138
+ * would tell an author their filter said no when nothing ever read it.
139
+ */
140
+ function createCommitMessageTraceEntry(args) {
141
+ const shown = args.text === void 0 ? "(absent)" : args.text.length > 200 ? `${args.text.slice(0, 200)}…` : args.text;
142
+ return createTraceEntry(TraceCheck.CommitMessage, describeTextMatch(args.match), verdictFor(args.passed, args.indeterminate === true), args.passed, args.reason ?? `message: ${JSON.stringify(shown)}`);
143
+ }
144
+ /**
145
+ * What a withheld trace field is replaced with when the reader does not hold
146
+ * `event_log:read_payload`.
147
+ *
148
+ * Lives here rather than beside the Platform's redactor because three packages
149
+ * read it: the Platform writes it, the dashboard renders it, and the E2E suite
150
+ * asserts the permission boundary against it. A literal repeated at each site
151
+ * would drift into a marker one of them no longer recognizes.
152
+ */
153
+ const REDACTED_TRACE_FIELD = "[redacted — requires event_log:read_payload]";
154
+ /**
155
+ * Workflow name the truncation marker carries.
156
+ *
157
+ * A reserved sentinel rather than a real workflow: it is written by one package
158
+ * and read back by two others, so a literal repeated at each site would drift
159
+ * into a marker nobody recognizes.
160
+ */
161
+ const TRACE_TRUNCATION_WORKFLOW_NAME = "(trace truncated)";
162
+ /**
163
+ * Build the marker that stands in for the decisions a size budget dropped.
164
+ *
165
+ * The trace is truncated rather than discarded: a reader who is told nothing
166
+ * cannot tell "matching never ran" from "the trace was too large to keep", and
167
+ * those two have opposite answers to "why did my workflow not fire".
168
+ */
169
+ function createTraceTruncationMarker(omitted) {
170
+ return {
171
+ workflowName: TRACE_TRUNCATION_WORKFLOW_NAME,
172
+ matched: false,
173
+ traceTruncated: true,
174
+ decisionsOmitted: omitted,
175
+ checks: [],
176
+ checksCount: 0,
177
+ summary: (omitted === 1 ? "1 further workflow decision was dropped: " : `${omitted} further workflow decisions were dropped: `) + "the trace exceeded the size budget for this delivery."
178
+ };
179
+ }
180
+ /**
181
+ * UTF-8 byte length of `text`.
182
+ *
183
+ * Byte length, never `String.length`: the latter counts UTF-16 code units, so a
184
+ * CJK-heavy comment body measures at roughly a third of the bytes it actually
185
+ * costs on the wire and in the stored row.
186
+ */
187
+ function utf8ByteLength(text) {
188
+ return new TextEncoder().encode(text).length;
189
+ }
190
+ /**
191
+ * Drop trailing decisions until the serialized array fits `maxBytes`, appending
192
+ * a marker naming how many were dropped.
193
+ *
194
+ * Shared by the orchestrator, which bounds what it puts on the wire, and the
195
+ * Platform, which bounds what it stores. Two independent budgets over one
196
+ * shape: keeping one implementation is what stops them from disagreeing about
197
+ * what a truncated trace looks like.
198
+ */
199
+ function truncateDecisionsToByteBudget(decisions, maxBytes) {
200
+ if (utf8ByteLength(JSON.stringify(decisions)) <= maxBytes) return {
201
+ decisions: [...decisions],
202
+ omitted: 0
203
+ };
204
+ let used = 3 + utf8ByteLength(JSON.stringify(createTraceTruncationMarker(decisions.length)));
205
+ const kept = [];
206
+ for (const decision of decisions) {
207
+ const cost = utf8ByteLength(JSON.stringify(decision)) + 1;
208
+ if (used + cost > maxBytes) break;
209
+ used += cost;
210
+ kept.push(decision);
211
+ }
212
+ const omitted = decisions.length - kept.length;
213
+ return {
214
+ decisions: [...kept, createTraceTruncationMarker(omitted)],
215
+ omitted
13
216
  };
14
217
  }
15
218
  /**
@@ -25,6 +228,6 @@ function createWorkflowDecision(workflowName, matched, checks, matchedTrigger, s
25
228
  };
26
229
  }
27
230
  //#endregion
28
- export { createTraceEntry, createWorkflowDecision };
231
+ export { REDACTED_TRACE_FIELD, TRACE_TEXT_MAX, TRACE_TRUNCATION_WORKFLOW_NAME, TraceCheck, TraceVerdict, appendChecks, createCommitMessageTraceEntry, createContentRequirementsTraceEntry, createDispatchFailureTraceEntry, createGlobalFilterTraceEntry, createTraceEntry, createTraceTruncationMarker, createWorkflowDecision, truncateDecisionsToByteBudget, truncateTraceText, utf8ByteLength };
29
232
 
30
233
  //# sourceMappingURL=decision-trace.js.map
@@ -1,4 +1,5 @@
1
1
  import "../rolldown-runtime-ClRpJifh.js";
2
+ import { assertSafeRegex } from "../safe-regex.js";
2
3
  import { JSONPath } from "jsonpath-plus";
3
4
  //#region src/trigger/jsonpath-matcher.ts
4
5
  /**
@@ -81,7 +82,10 @@ function matchValue(results, expected) {
81
82
  function valueEquals(result, expected) {
82
83
  if (typeof expected === "string" && typeof result === "string") {
83
84
  const regexMatch = /^\/(.+)\/([gimsuy]*)$/.exec(expected);
84
- if (regexMatch) return new RegExp(regexMatch[1], regexMatch[2]).test(result);
85
+ if (regexMatch) {
86
+ assertSafeRegex(regexMatch[1], regexMatch[2], "jsonpath match");
87
+ return new RegExp(regexMatch[1], regexMatch[2]).test(result);
88
+ }
85
89
  return result === expected;
86
90
  }
87
91
  return result === expected;