@kici-dev/engine 0.3.0 → 0.5.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/dist/context/host-match.js +2 -1
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +2 -1
- package/dist/context/secret-key.d.ts +48 -0
- package/dist/context/secret-key.js +64 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +8 -5
- package/dist/labels/compile.d.ts +2 -7
- package/dist/labels/compile.js +1 -10
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +9 -1
- package/dist/metrics/catalog-policy.d.ts +11 -0
- package/dist/metrics/catalog-policy.js +13 -3
- package/dist/metrics/metric-catalog.generated.d.ts +30 -0
- package/dist/metrics/metric-catalog.generated.js +36 -0
- package/dist/protocol/messages/dashboard-global-workflows.d.ts +8 -3
- package/dist/protocol/messages/dashboard-global-workflows.js +20 -3
- package/dist/protocol/messages/dashboard.d.ts +5 -2
- package/dist/protocol/messages/dashboard.js +7 -0
- package/dist/protocol/messages/execution-status.d.ts +3 -0
- package/dist/protocol/messages/execution-status.js +16 -0
- package/dist/protocol/messages/orchestrator-agent.d.ts +49 -0
- package/dist/protocol/messages/orchestrator-agent.js +34 -1
- package/dist/protocol/messages/platform-orchestrator.d.ts +5 -2
- package/dist/protocol/messages/platform-orchestrator.js +14 -0
- package/dist/provider/check-status-poster.d.ts +14 -0
- package/dist/provider/file-contents-fetcher.d.ts +39 -0
- package/dist/provider/file-contents-fetcher.js +2 -0
- package/dist/provider/index.d.ts +2 -0
- package/dist/safe-regex.d.ts +16 -0
- package/dist/safe-regex.js +24 -0
- package/dist/trigger/compiled-matchers.d.ts +21 -1
- package/dist/trigger/compiled-matchers.js +30 -3
- package/dist/trigger/content-requirements.d.ts +31 -0
- package/dist/trigger/content-requirements.js +125 -0
- package/dist/trigger/decision-trace.d.ts +89 -0
- package/dist/trigger/decision-trace.js +96 -1
- package/dist/trigger/jsonpath-matcher.js +5 -1
- package/dist/trigger/matcher.js +57 -8
- package/dist/trigger/text-match.d.ts +27 -0
- package/dist/trigger/text-match.js +86 -0
- package/dist/trigger/types.d.ts +179 -17
- package/dist/trigger/types.js +32 -5
- package/package.json +10 -1
- package/sbom.spdx.json +30 -5
|
@@ -3,11 +3,31 @@
|
|
|
3
3
|
* flags first with a space separator; regex flags are restricted to
|
|
4
4
|
* `[gimsuydv]`, so the separator can never collide with a pattern that starts
|
|
5
5
|
* with a space.
|
|
6
|
+
*
|
|
7
|
+
* Every pattern reaching here is author-supplied (branch/tag/repo patterns and
|
|
8
|
+
* comment `bodyMatch`), so it is ReDoS-checked before compiling. The guard runs
|
|
9
|
+
* inside the memo factory, so a benign pattern pays the check once (on cache
|
|
10
|
+
* miss) and a catastrophic one throws — and, being unsafe, is never cached, so
|
|
11
|
+
* it is rejected on every attempt. `ctx` names the dialect in the thrown error.
|
|
6
12
|
*/
|
|
7
|
-
export declare function getCompiledRegex(pattern: string, flags?: string): RegExp;
|
|
13
|
+
export declare function getCompiledRegex(pattern: string, flags?: string, ctx?: string): RegExp;
|
|
8
14
|
/**
|
|
9
15
|
* Return a memoized picomatch matcher function for the given glob pattern.
|
|
10
16
|
* `getGlobMatcher(pattern)(str)` is equivalent to `picomatch.isMatch(str, pattern)`.
|
|
11
17
|
*/
|
|
12
18
|
export declare function getGlobMatcher(pattern: string): (s: string) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Repo-identifier matcher. Identical to {@link getGlobMatcher} except it sets
|
|
21
|
+
* picomatch's `dot: true`, so a dot-prefixed identifier (`.hidden/repo`) is
|
|
22
|
+
* matched by `**` — `repos: ['**']` is documented as "every repo in the org",
|
|
23
|
+
* and silently excluding a whole class of identifier contradicts that.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately NOT the shared matcher: `dot: true` on path globs would make
|
|
26
|
+
* `paths: ['**']` start matching `.github/**` for every existing workflow, a
|
|
27
|
+
* semantics change this does not intend.
|
|
28
|
+
*
|
|
29
|
+
* Note a bare `.` still does not match, in either mode — picomatch will not
|
|
30
|
+
* match a lone `.`. That is upstream behavior, not something this option fixes.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getRepoGlobMatcher(pattern: string): (s: string) => boolean;
|
|
13
33
|
//# sourceMappingURL=compiled-matchers.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "../rolldown-runtime-ClRpJifh.js";
|
|
2
|
+
import { assertSafeRegex } from "../safe-regex.js";
|
|
2
3
|
import picomatch from "picomatch";
|
|
3
4
|
//#region src/trigger/compiled-matchers.ts
|
|
4
5
|
/**
|
|
@@ -22,14 +23,24 @@ function boundedGet(cache, key, make) {
|
|
|
22
23
|
}
|
|
23
24
|
const regexCache = /* @__PURE__ */ new Map();
|
|
24
25
|
const globCache = /* @__PURE__ */ new Map();
|
|
26
|
+
const repoGlobCache = /* @__PURE__ */ new Map();
|
|
25
27
|
/**
|
|
26
28
|
* Return a memoized RegExp for the given pattern + flags. The key encodes the
|
|
27
29
|
* flags first with a space separator; regex flags are restricted to
|
|
28
30
|
* `[gimsuydv]`, so the separator can never collide with a pattern that starts
|
|
29
31
|
* with a space.
|
|
32
|
+
*
|
|
33
|
+
* Every pattern reaching here is author-supplied (branch/tag/repo patterns and
|
|
34
|
+
* comment `bodyMatch`), so it is ReDoS-checked before compiling. The guard runs
|
|
35
|
+
* inside the memo factory, so a benign pattern pays the check once (on cache
|
|
36
|
+
* miss) and a catastrophic one throws — and, being unsafe, is never cached, so
|
|
37
|
+
* it is rejected on every attempt. `ctx` names the dialect in the thrown error.
|
|
30
38
|
*/
|
|
31
|
-
function getCompiledRegex(pattern, flags) {
|
|
32
|
-
return boundedGet(regexCache, `${flags ?? ""} ${pattern}`, () =>
|
|
39
|
+
function getCompiledRegex(pattern, flags, ctx = "trigger pattern") {
|
|
40
|
+
return boundedGet(regexCache, `${flags ?? ""} ${pattern}`, () => {
|
|
41
|
+
assertSafeRegex(pattern, flags ?? "", ctx);
|
|
42
|
+
return new RegExp(pattern, flags);
|
|
43
|
+
});
|
|
33
44
|
}
|
|
34
45
|
/**
|
|
35
46
|
* Return a memoized picomatch matcher function for the given glob pattern.
|
|
@@ -38,7 +49,23 @@ function getCompiledRegex(pattern, flags) {
|
|
|
38
49
|
function getGlobMatcher(pattern) {
|
|
39
50
|
return boundedGet(globCache, pattern, () => picomatch(pattern));
|
|
40
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Repo-identifier matcher. Identical to {@link getGlobMatcher} except it sets
|
|
54
|
+
* picomatch's `dot: true`, so a dot-prefixed identifier (`.hidden/repo`) is
|
|
55
|
+
* matched by `**` — `repos: ['**']` is documented as "every repo in the org",
|
|
56
|
+
* and silently excluding a whole class of identifier contradicts that.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately NOT the shared matcher: `dot: true` on path globs would make
|
|
59
|
+
* `paths: ['**']` start matching `.github/**` for every existing workflow, a
|
|
60
|
+
* semantics change this does not intend.
|
|
61
|
+
*
|
|
62
|
+
* Note a bare `.` still does not match, in either mode — picomatch will not
|
|
63
|
+
* match a lone `.`. That is upstream behavior, not something this option fixes.
|
|
64
|
+
*/
|
|
65
|
+
function getRepoGlobMatcher(pattern) {
|
|
66
|
+
return boundedGet(repoGlobCache, pattern, () => picomatch(pattern, { dot: true }));
|
|
67
|
+
}
|
|
41
68
|
//#endregion
|
|
42
|
-
export { getCompiledRegex, getGlobMatcher };
|
|
69
|
+
export { getCompiledRegex, getGlobMatcher, getRepoGlobMatcher };
|
|
43
70
|
|
|
44
71
|
//# sourceMappingURL=compiled-matchers.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LockContentRequirement } from './types.js';
|
|
2
|
+
export type { ContentFormat, ContentRequirement, LockContentRequirement } from './types.js';
|
|
3
|
+
/** Result of evaluating a `requires` list: a definite verdict, or fail-visible indeterminate. */
|
|
4
|
+
export interface ContentRequirementResult {
|
|
5
|
+
readonly pass: boolean;
|
|
6
|
+
/** Set (with `pass:false`) when a file could not be evaluated — never a silent pass. */
|
|
7
|
+
readonly indeterminate?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse `bytes` for the given concrete format. `text` returns the raw string;
|
|
11
|
+
* `json` uses `JSON.parse`; `yaml` uses a hardened `yaml.parse` with an explicit
|
|
12
|
+
* anchor/alias cap. Throws on a malformed document (the caller treats a throw as
|
|
13
|
+
* indeterminate).
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseForFormat(bytes: string, format: 'json' | 'yaml' | 'text'): unknown;
|
|
16
|
+
/**
|
|
17
|
+
* Evaluate an AND-ed list of content requirements against a resolved file map.
|
|
18
|
+
*
|
|
19
|
+
* Every entry must pass for the overall result to pass; an empty list passes.
|
|
20
|
+
* The first indeterminate entry short-circuits and is surfaced (fail-visible):
|
|
21
|
+
* an unevaluable file NEVER passes silently.
|
|
22
|
+
*
|
|
23
|
+
* @param reqs The lock `requires` list (each entry AND-ed).
|
|
24
|
+
* @param files Resolved file contents keyed by repo-relative path. A missing key
|
|
25
|
+
* or `{ present: false }` means the file does not exist at the ref.
|
|
26
|
+
*/
|
|
27
|
+
export declare function evaluateContentRequirements(reqs: readonly LockContentRequirement[], files: Map<string, {
|
|
28
|
+
present: boolean;
|
|
29
|
+
bytes?: string;
|
|
30
|
+
}>): ContentRequirementResult;
|
|
31
|
+
//# sourceMappingURL=content-requirements.d.ts.map
|
|
@@ -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 = 1024 * 1024;
|
|
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
|
*/
|
|
@@ -34,6 +35,94 @@ export interface WorkflowDecision {
|
|
|
34
35
|
* Create a new trace entry.
|
|
35
36
|
*/
|
|
36
37
|
export declare function createTraceEntry(check: string, pattern: string, value: string, passed: boolean, reason?: string): TraceEntry;
|
|
38
|
+
/**
|
|
39
|
+
* Stable `check` labels for the gates an organization-wide global workflow
|
|
40
|
+
* passes on its way to dispatch.
|
|
41
|
+
*
|
|
42
|
+
* The other trigger checks are minted with free-text labels because each one
|
|
43
|
+
* names the trigger field it read. These are named constants instead: they are
|
|
44
|
+
* recorded in one package and read back in another — by code, and by whoever is
|
|
45
|
+
* asking "why did nothing run?" — so a typo on either side would silently
|
|
46
|
+
* produce a trace nobody can search for.
|
|
47
|
+
*/
|
|
48
|
+
export declare const TraceCheck: {
|
|
49
|
+
/** `repos` glob/regex filter, deciding whether a workflow applies to the event's repo. */
|
|
50
|
+
readonly RepoFilter: 'repo';
|
|
51
|
+
/** Tier-1 declarative `requires` content filter, interpreted by the orchestrator. */
|
|
52
|
+
readonly ContentRequirements: 'requires';
|
|
53
|
+
/** Tier-2 `filter` predicate, run by an agent in the global eval round. */
|
|
54
|
+
readonly GlobalFilter: 'filter';
|
|
55
|
+
/** Tier-0 declarative `commitMessage` filter, read from the normalized event. */
|
|
56
|
+
readonly CommitMessage: 'commitMessage';
|
|
57
|
+
};
|
|
58
|
+
export type TraceCheck = (typeof TraceCheck)[keyof typeof TraceCheck];
|
|
59
|
+
/**
|
|
60
|
+
* Verdict vocabulary shared by both gates.
|
|
61
|
+
*
|
|
62
|
+
* `Indeterminate` is deliberately distinct from `Excluded`: a gate that could
|
|
63
|
+
* not be evaluated did not decide anything, and reporting it as an exclusion
|
|
64
|
+
* would tell a workflow author their filter said no when nothing ever ran it.
|
|
65
|
+
*/
|
|
66
|
+
export declare const TraceVerdict: {
|
|
67
|
+
readonly Matched: 'matched';
|
|
68
|
+
readonly Excluded: 'excluded';
|
|
69
|
+
readonly Indeterminate: 'indeterminate';
|
|
70
|
+
};
|
|
71
|
+
export type TraceVerdict = (typeof TraceVerdict)[keyof typeof TraceVerdict];
|
|
72
|
+
/**
|
|
73
|
+
* Record the Tier-1 `requires` content filter's verdict for one workflow.
|
|
74
|
+
*
|
|
75
|
+
* `files` is the set of repo-relative paths the requirement list reads, so the
|
|
76
|
+
* entry names what was inspected as well as what it concluded.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createContentRequirementsTraceEntry(args: {
|
|
79
|
+
files: readonly string[];
|
|
80
|
+
passed: boolean;
|
|
81
|
+
/** Set when the requirement list could not be evaluated (unreadable file, bad parse). */
|
|
82
|
+
indeterminate?: boolean;
|
|
83
|
+
reason?: string;
|
|
84
|
+
}): TraceEntry;
|
|
85
|
+
/**
|
|
86
|
+
* Record the Tier-2 `filter` predicate's verdict for one workflow.
|
|
87
|
+
*
|
|
88
|
+
* Without this entry a `filter` exclusion is invisible: the predicate runs on an
|
|
89
|
+
* agent, returns `false`, and the workflow simply never appears — leaving its
|
|
90
|
+
* author nothing to inspect. The entry is the answer to "why did nothing run?".
|
|
91
|
+
*/
|
|
92
|
+
export declare function createGlobalFilterTraceEntry(args: {
|
|
93
|
+
/** The round's verdict for this candidate: `true` means the filter admitted it. */
|
|
94
|
+
run: boolean;
|
|
95
|
+
/** True when the round could not decide (a failed round, a budget breach). */
|
|
96
|
+
indeterminate?: boolean;
|
|
97
|
+
reason?: string;
|
|
98
|
+
}): TraceEntry;
|
|
99
|
+
/**
|
|
100
|
+
* Return a copy of `decision` with `entries` appended to its checks.
|
|
101
|
+
*
|
|
102
|
+
* A decision is treated as a value, never mutated in place: the same object is
|
|
103
|
+
* read by the caller that recorded it, and a gate appending to it would
|
|
104
|
+
* retroactively rewrite what an earlier reader saw.
|
|
105
|
+
*
|
|
106
|
+
* A failing appended entry demotes `matched` and replaces `summary`, because a
|
|
107
|
+
* workflow whose triggers matched but whose content filter excluded it did NOT
|
|
108
|
+
* match overall — reporting it as matched is precisely the invisible outcome
|
|
109
|
+
* these entries exist to explain.
|
|
110
|
+
*/
|
|
111
|
+
export declare function appendChecks(decision: WorkflowDecision, entries: readonly TraceEntry[]): WorkflowDecision;
|
|
112
|
+
/**
|
|
113
|
+
* Record the Tier-0 `commitMessage` filter's verdict for one trigger.
|
|
114
|
+
*
|
|
115
|
+
* `text: undefined` means the event carried no message — an INDETERMINATE
|
|
116
|
+
* verdict, deliberately distinct from an exclusion: reporting it as "excluded"
|
|
117
|
+
* would tell an author their filter said no when nothing ever read it.
|
|
118
|
+
*/
|
|
119
|
+
export declare function createCommitMessageTraceEntry(args: {
|
|
120
|
+
match: LockTextMatch;
|
|
121
|
+
text: string | undefined;
|
|
122
|
+
passed: boolean;
|
|
123
|
+
indeterminate?: boolean;
|
|
124
|
+
reason?: string;
|
|
125
|
+
}): TraceEntry;
|
|
37
126
|
/**
|
|
38
127
|
* Create a workflow decision record.
|
|
39
128
|
*/
|
|
@@ -1,6 +1,11 @@
|
|
|
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
|
+
/**
|
|
4
9
|
* Create a new trace entry.
|
|
5
10
|
*/
|
|
6
11
|
function createTraceEntry(check, pattern, value, passed, reason) {
|
|
@@ -13,6 +18,96 @@ function createTraceEntry(check, pattern, value, passed, reason) {
|
|
|
13
18
|
};
|
|
14
19
|
}
|
|
15
20
|
/**
|
|
21
|
+
* Stable `check` labels for the gates an organization-wide global workflow
|
|
22
|
+
* passes on its way to dispatch.
|
|
23
|
+
*
|
|
24
|
+
* The other trigger checks are minted with free-text labels because each one
|
|
25
|
+
* names the trigger field it read. These are named constants instead: they are
|
|
26
|
+
* recorded in one package and read back in another — by code, and by whoever is
|
|
27
|
+
* asking "why did nothing run?" — so a typo on either side would silently
|
|
28
|
+
* produce a trace nobody can search for.
|
|
29
|
+
*/
|
|
30
|
+
const TraceCheck = {
|
|
31
|
+
/** `repos` glob/regex filter, deciding whether a workflow applies to the event's repo. */
|
|
32
|
+
RepoFilter: "repo",
|
|
33
|
+
/** Tier-1 declarative `requires` content filter, interpreted by the orchestrator. */
|
|
34
|
+
ContentRequirements: "requires",
|
|
35
|
+
/** Tier-2 `filter` predicate, run by an agent in the global eval round. */
|
|
36
|
+
GlobalFilter: "filter",
|
|
37
|
+
/** Tier-0 declarative `commitMessage` filter, read from the normalized event. */
|
|
38
|
+
CommitMessage: "commitMessage"
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Verdict vocabulary shared by both gates.
|
|
42
|
+
*
|
|
43
|
+
* `Indeterminate` is deliberately distinct from `Excluded`: a gate that could
|
|
44
|
+
* not be evaluated did not decide anything, and reporting it as an exclusion
|
|
45
|
+
* would tell a workflow author their filter said no when nothing ever ran it.
|
|
46
|
+
*/
|
|
47
|
+
const TraceVerdict = {
|
|
48
|
+
Matched: "matched",
|
|
49
|
+
Excluded: "excluded",
|
|
50
|
+
Indeterminate: "indeterminate"
|
|
51
|
+
};
|
|
52
|
+
/** Pick the verdict for a gate from its pass flag and whether it could decide. */
|
|
53
|
+
function verdictFor(passed, indeterminate) {
|
|
54
|
+
if (indeterminate) return TraceVerdict.Indeterminate;
|
|
55
|
+
return passed ? TraceVerdict.Matched : TraceVerdict.Excluded;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Record the Tier-1 `requires` content filter's verdict for one workflow.
|
|
59
|
+
*
|
|
60
|
+
* `files` is the set of repo-relative paths the requirement list reads, so the
|
|
61
|
+
* entry names what was inspected as well as what it concluded.
|
|
62
|
+
*/
|
|
63
|
+
function createContentRequirementsTraceEntry(args) {
|
|
64
|
+
return createTraceEntry(TraceCheck.ContentRequirements, args.files.length > 0 ? args.files.join(", ") : "(no files)", verdictFor(args.passed, args.indeterminate === true), args.passed, args.reason);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Record the Tier-2 `filter` predicate's verdict for one workflow.
|
|
68
|
+
*
|
|
69
|
+
* Without this entry a `filter` exclusion is invisible: the predicate runs on an
|
|
70
|
+
* agent, returns `false`, and the workflow simply never appears — leaving its
|
|
71
|
+
* author nothing to inspect. The entry is the answer to "why did nothing run?".
|
|
72
|
+
*/
|
|
73
|
+
function createGlobalFilterTraceEntry(args) {
|
|
74
|
+
return createTraceEntry(TraceCheck.GlobalFilter, "filter(context) === true", verdictFor(args.run, args.indeterminate === true), args.run, args.reason);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Return a copy of `decision` with `entries` appended to its checks.
|
|
78
|
+
*
|
|
79
|
+
* A decision is treated as a value, never mutated in place: the same object is
|
|
80
|
+
* read by the caller that recorded it, and a gate appending to it would
|
|
81
|
+
* retroactively rewrite what an earlier reader saw.
|
|
82
|
+
*
|
|
83
|
+
* A failing appended entry demotes `matched` and replaces `summary`, because a
|
|
84
|
+
* workflow whose triggers matched but whose content filter excluded it did NOT
|
|
85
|
+
* match overall — reporting it as matched is precisely the invisible outcome
|
|
86
|
+
* these entries exist to explain.
|
|
87
|
+
*/
|
|
88
|
+
function appendChecks(decision, entries) {
|
|
89
|
+
const failed = entries.find((entry) => !entry.passed);
|
|
90
|
+
return {
|
|
91
|
+
...decision,
|
|
92
|
+
matched: decision.matched && failed === void 0,
|
|
93
|
+
checks: [...decision.checks, ...entries],
|
|
94
|
+
summary: failed ? failed.reason ?? `Excluded by the ${failed.check} check` : decision.summary
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Max characters of the message recorded in a trace, so an essay-length body stays bounded. */
|
|
98
|
+
const TRACE_TEXT_MAX = 200;
|
|
99
|
+
/**
|
|
100
|
+
* Record the Tier-0 `commitMessage` filter's verdict for one trigger.
|
|
101
|
+
*
|
|
102
|
+
* `text: undefined` means the event carried no message — an INDETERMINATE
|
|
103
|
+
* verdict, deliberately distinct from an exclusion: reporting it as "excluded"
|
|
104
|
+
* would tell an author their filter said no when nothing ever read it.
|
|
105
|
+
*/
|
|
106
|
+
function createCommitMessageTraceEntry(args) {
|
|
107
|
+
const shown = args.text === void 0 ? "(absent)" : args.text.length > TRACE_TEXT_MAX ? `${args.text.slice(0, TRACE_TEXT_MAX)}…` : args.text;
|
|
108
|
+
return createTraceEntry(TraceCheck.CommitMessage, describeTextMatch(args.match), verdictFor(args.passed, args.indeterminate === true), args.passed, args.reason ?? `message: ${JSON.stringify(shown)}`);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
16
111
|
* Create a workflow decision record.
|
|
17
112
|
*/
|
|
18
113
|
function createWorkflowDecision(workflowName, matched, checks, matchedTrigger, summary) {
|
|
@@ -25,6 +120,6 @@ function createWorkflowDecision(workflowName, matched, checks, matchedTrigger, s
|
|
|
25
120
|
};
|
|
26
121
|
}
|
|
27
122
|
//#endregion
|
|
28
|
-
export { createTraceEntry, createWorkflowDecision };
|
|
123
|
+
export { TraceCheck, TraceVerdict, appendChecks, createCommitMessageTraceEntry, createContentRequirementsTraceEntry, createGlobalFilterTraceEntry, createTraceEntry, createWorkflowDecision };
|
|
29
124
|
|
|
30
125
|
//# 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)
|
|
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;
|
package/dist/trigger/matcher.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "../rolldown-runtime-ClRpJifh.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { evaluateTextMatch } from "./text-match.js";
|
|
3
|
+
import { TraceCheck, createCommitMessageTraceEntry, createTraceEntry, createWorkflowDecision } from "./decision-trace.js";
|
|
4
|
+
import { getCompiledRegex, getGlobMatcher, getRepoGlobMatcher } from "./compiled-matchers.js";
|
|
4
5
|
import { matchJsonPath, matchJsonPathNot } from "./jsonpath-matcher.js";
|
|
5
6
|
//#region src/trigger/matcher.ts
|
|
6
7
|
/**
|
|
@@ -35,7 +36,21 @@ function splitBranchPatterns(patterns) {
|
|
|
35
36
|
*/
|
|
36
37
|
function matchBranchPattern(pattern, branch) {
|
|
37
38
|
if (pattern.type === "glob") return getGlobMatcher(pattern.pattern)(branch);
|
|
38
|
-
else return getCompiledRegex(pattern.pattern, pattern.flags).test(branch);
|
|
39
|
+
else return getCompiledRegex(pattern.pattern, pattern.flags, "branch/tag/repo pattern").test(branch);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Match a repo pattern against a repository identifier.
|
|
43
|
+
*
|
|
44
|
+
* Same shape as {@link matchBranchPattern}, but glob patterns compile through
|
|
45
|
+
* {@link getRepoGlobMatcher} so a dot-prefixed identifier (`.hidden/repo`) is
|
|
46
|
+
* matched by `**`. Repo identifiers are org/name pairs, not paths, so there is
|
|
47
|
+
* no dotfile convention to respect — and `repos: ['**']` means every repo.
|
|
48
|
+
*
|
|
49
|
+
* Regex patterns are unaffected: `dot` is a glob option with no regex analogue.
|
|
50
|
+
*/
|
|
51
|
+
function matchRepoPattern(pattern, repo) {
|
|
52
|
+
if (pattern.type === "glob") return getRepoGlobMatcher(pattern.pattern)(repo);
|
|
53
|
+
return getCompiledRegex(pattern.pattern, pattern.flags, "branch/tag/repo pattern").test(repo);
|
|
39
54
|
}
|
|
40
55
|
/**
|
|
41
56
|
* Match any of the branch patterns.
|
|
@@ -73,10 +88,10 @@ function matchRepoPatterns(repos, sourceRepo) {
|
|
|
73
88
|
if (repos.length === 0) return true;
|
|
74
89
|
const { include, exclude } = splitBranchPatterns(repos);
|
|
75
90
|
if (exclude.length > 0) {
|
|
76
|
-
if (exclude.some((p) =>
|
|
91
|
+
if (exclude.some((p) => matchRepoPattern(p, sourceRepo))) return false;
|
|
77
92
|
}
|
|
78
93
|
if (include.length === 0) return true;
|
|
79
|
-
return include.some((p) =>
|
|
94
|
+
return include.some((p) => matchRepoPattern(p, sourceRepo));
|
|
80
95
|
}
|
|
81
96
|
/**
|
|
82
97
|
* Evaluate repo pattern filter for a trigger against an event.
|
|
@@ -86,17 +101,48 @@ function matchRepoPatterns(repos, sourceRepo) {
|
|
|
86
101
|
function evaluateRepoFilter(trigger, event, traces) {
|
|
87
102
|
if (trigger.repos?.length) {
|
|
88
103
|
if (!event.sourceRepo) {
|
|
89
|
-
traces.push(createTraceEntry(
|
|
104
|
+
traces.push(createTraceEntry(TraceCheck.RepoFilter, "required", "(missing)", false));
|
|
90
105
|
return false;
|
|
91
106
|
}
|
|
92
107
|
const { include, exclude } = splitBranchPatterns(trigger.repos);
|
|
93
108
|
const repoMatch = matchRepoPatterns(trigger.repos, event.sourceRepo);
|
|
94
|
-
traces.push(createTraceEntry(
|
|
109
|
+
traces.push(createTraceEntry(TraceCheck.RepoFilter, `include:[${include.map((p) => p.pattern).join(",")}] exclude:[${exclude.map((p) => p.pattern).join(",")}]`, event.sourceRepo, repoMatch));
|
|
95
110
|
if (!repoMatch) return false;
|
|
96
111
|
}
|
|
97
112
|
return true;
|
|
98
113
|
}
|
|
99
114
|
/**
|
|
115
|
+
* Evaluate a trigger's `commitMessage` filter. Returns true when the trigger
|
|
116
|
+
* declares none (the fast path).
|
|
117
|
+
*
|
|
118
|
+
* An event with no message is INDETERMINATE, not a clean exclusion: the filter
|
|
119
|
+
* is fail-visible, mirroring the Tier-1 `requires` gate, so a workflow whose
|
|
120
|
+
* declared gate was never evaluated does not run.
|
|
121
|
+
*/
|
|
122
|
+
function evaluateCommitMessageFilter(trigger, event, traces) {
|
|
123
|
+
const match = trigger.commitMessage;
|
|
124
|
+
if (!match) return true;
|
|
125
|
+
if (event.commitMessage === void 0) {
|
|
126
|
+
traces.push(createCommitMessageTraceEntry({
|
|
127
|
+
match,
|
|
128
|
+
text: void 0,
|
|
129
|
+
passed: false,
|
|
130
|
+
indeterminate: true,
|
|
131
|
+
reason: "no commit message in payload (provider carries none for this event)"
|
|
132
|
+
}));
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const result = evaluateTextMatch(event.commitMessage, match);
|
|
136
|
+
traces.push(createCommitMessageTraceEntry({
|
|
137
|
+
match,
|
|
138
|
+
text: event.commitMessage,
|
|
139
|
+
passed: result.pass,
|
|
140
|
+
indeterminate: result.indeterminate !== void 0,
|
|
141
|
+
...result.indeterminate !== void 0 && { reason: result.indeterminate }
|
|
142
|
+
}));
|
|
143
|
+
return result.pass;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
100
146
|
* Match a PR trigger against a simulated event.
|
|
101
147
|
*/
|
|
102
148
|
function matchPrTrigger(trigger, event, traces) {
|
|
@@ -132,6 +178,7 @@ function matchPrTrigger(trigger, event, traces) {
|
|
|
132
178
|
traces.push(createTraceEntry("paths", `include: [${include.join(", ")}] exclude: [${exclude.join(", ")}]`, event.changedFilesStatus === "unavailable" ? "[unavailable — matched conservatively]" : `[${changedFiles.join(", ")}]`, matches));
|
|
133
179
|
if (!matches) return false;
|
|
134
180
|
}
|
|
181
|
+
if (!evaluateCommitMessageFilter(trigger, event, traces)) return false;
|
|
135
182
|
if (!evaluateRepoFilter(trigger, event, traces)) return false;
|
|
136
183
|
return true;
|
|
137
184
|
}
|
|
@@ -156,6 +203,7 @@ function matchPushTrigger(trigger, event, traces) {
|
|
|
156
203
|
traces.push(createTraceEntry("paths", `include: [${include.join(", ")}] exclude: [${exclude.join(", ")}]`, event.changedFilesStatus === "unavailable" ? "[unavailable — matched conservatively]" : `[${changedFiles.join(", ")}]`, matches));
|
|
157
204
|
if (!matches) return false;
|
|
158
205
|
}
|
|
206
|
+
if (!evaluateCommitMessageFilter(trigger, event, traces)) return false;
|
|
159
207
|
if (!evaluateRepoFilter(trigger, event, traces)) return false;
|
|
160
208
|
return true;
|
|
161
209
|
}
|
|
@@ -174,6 +222,7 @@ function matchTagTrigger(trigger, event, traces) {
|
|
|
174
222
|
traces.push(createTraceEntry("tag pattern", trigger.patterns.map((p) => p.pattern).join("|"), event.targetBranch, matches));
|
|
175
223
|
if (!matches) return false;
|
|
176
224
|
}
|
|
225
|
+
if (!evaluateCommitMessageFilter(trigger, event, traces)) return false;
|
|
177
226
|
if (!evaluateRepoFilter(trigger, event, traces)) return false;
|
|
178
227
|
return true;
|
|
179
228
|
}
|
|
@@ -213,7 +262,7 @@ function matchCommentTrigger(trigger, event, traces) {
|
|
|
213
262
|
traces.push(createTraceEntry("bodyMatch (glob)", trigger.bodyMatch.pattern, body, matches));
|
|
214
263
|
if (!matches) return false;
|
|
215
264
|
} else {
|
|
216
|
-
const matches = getCompiledRegex(trigger.bodyMatch.pattern, trigger.bodyMatch.flags).test(body);
|
|
265
|
+
const matches = getCompiledRegex(trigger.bodyMatch.pattern, trigger.bodyMatch.flags, "comment bodyMatch").test(body);
|
|
217
266
|
traces.push(createTraceEntry("bodyMatch (regex)", trigger.bodyMatch.pattern, body, matches));
|
|
218
267
|
if (!matches) return false;
|
|
219
268
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LockTextMatch, TextMatch } from './types.js';
|
|
2
|
+
/** A definite verdict, or fail-visible indeterminate. Mirrors `ContentRequirementResult`. */
|
|
3
|
+
export interface TextMatchResult {
|
|
4
|
+
readonly pass: boolean;
|
|
5
|
+
/** Set (with `pass: false`) when the match could not be evaluated. */
|
|
6
|
+
readonly indeterminate?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Compile a `/pattern/flags` (or bare-pattern) string, rejecting a ReDoS-prone
|
|
10
|
+
* pattern. Returns `null` when the pattern is syntactically invalid or fails the
|
|
11
|
+
* `safe-regex` star-height heuristic — callers treat `null` as indeterminate.
|
|
12
|
+
*
|
|
13
|
+
* Always compiles a FRESH RegExp. A `g`-flagged instance carries `lastIndex`
|
|
14
|
+
* across `.test()` calls, so a cached one would return alternating verdicts for
|
|
15
|
+
* the same input.
|
|
16
|
+
*/
|
|
17
|
+
export declare function compileSafeRegex(source: string): RegExp | null;
|
|
18
|
+
/** True when the matcher carries at least one populated query key. */
|
|
19
|
+
export declare function textMatchHasQuery(m: TextMatch | LockTextMatch): boolean;
|
|
20
|
+
/** Render the populated keys for a decision-trace `pattern` field. */
|
|
21
|
+
export declare function describeTextMatch(m: LockTextMatch): string;
|
|
22
|
+
/**
|
|
23
|
+
* Evaluate a text match. Every entry in every list is a conjunct, and every
|
|
24
|
+
* populated key ANDs with the others; an empty matcher passes.
|
|
25
|
+
*/
|
|
26
|
+
export declare function evaluateTextMatch(text: string, m: LockTextMatch): TextMatchResult;
|
|
27
|
+
//# sourceMappingURL=text-match.d.ts.map
|