@webpieces/rules-config 0.4.654 → 0.4.656
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/package.json +1 -1
- package/src/fix-option.d.ts +32 -0
- package/src/fix-option.js +52 -0
- package/src/fix-option.js.map +1 -0
- package/src/index.d.ts +4 -2
- package/src/index.js +16 -7
- package/src/index.js.map +1 -1
- package/src/rule-configs.js +4 -0
- package/src/rule-configs.js.map +1 -1
- package/src/rule-fail-error.d.ts +24 -4
- package/src/rule-fail-error.js +38 -5
- package/src/rule-fail-error.js.map +1 -1
- package/src/skip-rule.d.ts +19 -3
- package/src/skip-rule.js +157 -21
- package/src/skip-rule.js.map +1 -1
- package/src/validate-config.d.ts +12 -0
- package/src/validate-config.js +93 -33
- package/src/validate-config.js.map +1 -1
package/src/rule-fail-error.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Option } from './fix-option';
|
|
1
2
|
/**
|
|
2
3
|
* Thrown by ANY rule — in `ai-hook-rules` (edit-time) OR `code-rules` (build/CI-time) — to report a
|
|
3
4
|
* failure from anywhere in its logic. Each engine wraps every rule in a per-rule try/catch, so a
|
|
@@ -14,12 +15,19 @@
|
|
|
14
15
|
* - `aiMessage` — what the AI sees in the ai-hook path (also `Error.message`).
|
|
15
16
|
* - `humanMessage` — what a developer/CI sees in the code-rules console (defaults to `aiMessage`).
|
|
16
17
|
*
|
|
17
|
-
* `line`/`snippet`/`
|
|
18
|
+
* `line`/`snippet`/`fixOptions` are optional context the ai-hook engine folds into its `Violation`.
|
|
19
|
+
*
|
|
20
|
+
* `fixOptions` is `readonly Option[]` — the SAME `Option` that `FixHint.fixOptions` carries, so there is
|
|
21
|
+
* exactly ONE representation of "the list of cures" across both engines, and a build-time rule can mark
|
|
22
|
+
* one cure `preferred` exactly like an edit-time rule can. It was `readonly string[]`; that spelling is
|
|
23
|
+
* deleted and does not compile. NEVER hand-number the cures inside `aiMessage` — the framework
|
|
24
|
+
* (`formatFixOptions`) owns the "Fix Option N:" numbering and the "(preferred)" tag.
|
|
18
25
|
*
|
|
19
26
|
* Constructor is positional to match this package's other data classes (`Violation`, `ResolvedConfig`)
|
|
20
27
|
* and the project's classes-over-interfaces convention. Common throws:
|
|
21
28
|
* throw new RuleFailError('no-any-unknown', 'Avoid `any` here — use `unknown`.', 42, 'const x: any');
|
|
22
|
-
* throw new RuleFailError('max-file-lines', 'File exceeds the limit.', undefined, undefined,
|
|
29
|
+
* throw new RuleFailError('max-file-lines', 'File exceeds the limit.', undefined, undefined,
|
|
30
|
+
* [new Option('Split it into two modules', true), new Option('Move the helpers to a sibling file')]);
|
|
23
31
|
*/
|
|
24
32
|
export declare class RuleFailError extends Error {
|
|
25
33
|
cause?: Error;
|
|
@@ -28,6 +36,18 @@ export declare class RuleFailError extends Error {
|
|
|
28
36
|
readonly humanMessage: string;
|
|
29
37
|
readonly line: number | undefined;
|
|
30
38
|
readonly snippet: string | undefined;
|
|
31
|
-
readonly
|
|
32
|
-
constructor(ruleName: string, aiMessage: string, line?: number, snippet?: string,
|
|
39
|
+
readonly fixOptions: readonly Option[];
|
|
40
|
+
constructor(ruleName: string, aiMessage: string, line?: number, snippet?: string, fixOptions?: readonly Option[], humanMessage?: string, cause?: Error);
|
|
33
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The AI-audience rendering of a thrown `RuleFailError`: its `aiMessage` plus its cures, numbered and
|
|
44
|
+
* tagged by the framework. Used by the top-level handlers in `ai-hook-rules`.
|
|
45
|
+
*
|
|
46
|
+
* WHY it exists: every handler used to print `aiMessage`/`humanMessage` alone, so a `RuleFailError`
|
|
47
|
+
* that escaped a per-rule catch reached the AI or the CI console with its `fixOptions` SILENTLY
|
|
48
|
+
* DROPPED — the rule had said how to fix the problem and the renderer threw that away. One renderer per
|
|
49
|
+
* audience means a cure cannot go missing depending on which catch caught the throw.
|
|
50
|
+
*/
|
|
51
|
+
export declare function renderRuleFailForAi(error: RuleFailError): string;
|
|
52
|
+
/** The developer/CI rendering: `humanMessage` (which defaults to `aiMessage`) plus the same cures. */
|
|
53
|
+
export declare function renderRuleFailForHuman(error: RuleFailError): string;
|
package/src/rule-fail-error.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RuleFailError = void 0;
|
|
4
|
+
exports.renderRuleFailForAi = renderRuleFailForAi;
|
|
5
|
+
exports.renderRuleFailForHuman = renderRuleFailForHuman;
|
|
6
|
+
const fix_option_1 = require("./fix-option");
|
|
4
7
|
/**
|
|
5
8
|
* Thrown by ANY rule — in `ai-hook-rules` (edit-time) OR `code-rules` (build/CI-time) — to report a
|
|
6
9
|
* failure from anywhere in its logic. Each engine wraps every rule in a per-rule try/catch, so a
|
|
@@ -17,12 +20,19 @@ exports.RuleFailError = void 0;
|
|
|
17
20
|
* - `aiMessage` — what the AI sees in the ai-hook path (also `Error.message`).
|
|
18
21
|
* - `humanMessage` — what a developer/CI sees in the code-rules console (defaults to `aiMessage`).
|
|
19
22
|
*
|
|
20
|
-
* `line`/`snippet`/`
|
|
23
|
+
* `line`/`snippet`/`fixOptions` are optional context the ai-hook engine folds into its `Violation`.
|
|
24
|
+
*
|
|
25
|
+
* `fixOptions` is `readonly Option[]` — the SAME `Option` that `FixHint.fixOptions` carries, so there is
|
|
26
|
+
* exactly ONE representation of "the list of cures" across both engines, and a build-time rule can mark
|
|
27
|
+
* one cure `preferred` exactly like an edit-time rule can. It was `readonly string[]`; that spelling is
|
|
28
|
+
* deleted and does not compile. NEVER hand-number the cures inside `aiMessage` — the framework
|
|
29
|
+
* (`formatFixOptions`) owns the "Fix Option N:" numbering and the "(preferred)" tag.
|
|
21
30
|
*
|
|
22
31
|
* Constructor is positional to match this package's other data classes (`Violation`, `ResolvedConfig`)
|
|
23
32
|
* and the project's classes-over-interfaces convention. Common throws:
|
|
24
33
|
* throw new RuleFailError('no-any-unknown', 'Avoid `any` here — use `unknown`.', 42, 'const x: any');
|
|
25
|
-
* throw new RuleFailError('max-file-lines', 'File exceeds the limit.', undefined, undefined,
|
|
34
|
+
* throw new RuleFailError('max-file-lines', 'File exceeds the limit.', undefined, undefined,
|
|
35
|
+
* [new Option('Split it into two modules', true), new Option('Move the helpers to a sibling file')]);
|
|
26
36
|
*/
|
|
27
37
|
class RuleFailError extends Error {
|
|
28
38
|
cause;
|
|
@@ -31,8 +41,8 @@ class RuleFailError extends Error {
|
|
|
31
41
|
humanMessage;
|
|
32
42
|
line;
|
|
33
43
|
snippet;
|
|
34
|
-
|
|
35
|
-
constructor(ruleName, aiMessage, line, snippet,
|
|
44
|
+
fixOptions;
|
|
45
|
+
constructor(ruleName, aiMessage, line, snippet, fixOptions = [], humanMessage, cause) {
|
|
36
46
|
super(aiMessage);
|
|
37
47
|
this.name = 'RuleFailError';
|
|
38
48
|
this.ruleName = ruleName;
|
|
@@ -40,9 +50,32 @@ class RuleFailError extends Error {
|
|
|
40
50
|
this.humanMessage = humanMessage ?? aiMessage;
|
|
41
51
|
this.line = line;
|
|
42
52
|
this.snippet = snippet;
|
|
43
|
-
this.
|
|
53
|
+
this.fixOptions = fixOptions;
|
|
44
54
|
this.cause = cause;
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
57
|
exports.RuleFailError = RuleFailError;
|
|
58
|
+
/**
|
|
59
|
+
* The AI-audience rendering of a thrown `RuleFailError`: its `aiMessage` plus its cures, numbered and
|
|
60
|
+
* tagged by the framework. Used by the top-level handlers in `ai-hook-rules`.
|
|
61
|
+
*
|
|
62
|
+
* WHY it exists: every handler used to print `aiMessage`/`humanMessage` alone, so a `RuleFailError`
|
|
63
|
+
* that escaped a per-rule catch reached the AI or the CI console with its `fixOptions` SILENTLY
|
|
64
|
+
* DROPPED — the rule had said how to fix the problem and the renderer threw that away. One renderer per
|
|
65
|
+
* audience means a cure cannot go missing depending on which catch caught the throw.
|
|
66
|
+
*/
|
|
67
|
+
// webpieces-disable no-function-outside-class -- string formatter, sibling of formatFixOptions
|
|
68
|
+
function renderRuleFailForAi(error) {
|
|
69
|
+
return joinCures(error.aiMessage, error.fixOptions);
|
|
70
|
+
}
|
|
71
|
+
/** The developer/CI rendering: `humanMessage` (which defaults to `aiMessage`) plus the same cures. */
|
|
72
|
+
// webpieces-disable no-function-outside-class -- string formatter, sibling of formatFixOptions
|
|
73
|
+
function renderRuleFailForHuman(error) {
|
|
74
|
+
return joinCures(error.humanMessage, error.fixOptions);
|
|
75
|
+
}
|
|
76
|
+
// webpieces-disable no-function-outside-class -- private helper of the two renderers above
|
|
77
|
+
function joinCures(message, fixOptions) {
|
|
78
|
+
const rendered = (0, fix_option_1.formatFixOptions)(fixOptions);
|
|
79
|
+
return rendered.length === 0 ? message : `${message}\n${rendered.join('\n')}`;
|
|
80
|
+
}
|
|
48
81
|
//# sourceMappingURL=rule-fail-error.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rule-fail-error.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/rule-fail-error.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"rule-fail-error.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/rule-fail-error.ts"],"names":[],"mappings":";;;AAwEA,kDAEC;AAID,wDAEC;AAhFD,6CAAwD;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAa,aAAc,SAAQ,KAAK;IAC3B,KAAK,CAAS;IACd,QAAQ,CAAS;IACjB,SAAS,CAAS;IAClB,YAAY,CAAS;IACrB,IAAI,CAAqB;IACzB,OAAO,CAAqB;IAC5B,UAAU,CAAoB;IAEvC,YACI,QAAgB,EAChB,SAAiB,EACjB,IAAa,EACb,OAAgB,EAChB,aAAgC,EAAE,EAClC,YAAqB,EACrB,KAAa;QAEb,KAAK,CAAC,SAAS,CAAC,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,YAAY,GAAG,YAAY,IAAI,SAAS,CAAC;QAC9C,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;CACJ;AA5BD,sCA4BC;AAED;;;;;;;;GAQG;AACH,+FAA+F;AAC/F,SAAgB,mBAAmB,CAAC,KAAoB;IACpD,OAAO,SAAS,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;AACxD,CAAC;AAED,sGAAsG;AACtG,+FAA+F;AAC/F,SAAgB,sBAAsB,CAAC,KAAoB;IACvD,OAAO,SAAS,CAAC,KAAK,CAAC,YAAY,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;AAC3D,CAAC;AAED,2FAA2F;AAC3F,SAAS,SAAS,CAAC,OAAe,EAAE,UAA6B;IAC7D,MAAM,QAAQ,GAAG,IAAA,6BAAgB,EAAC,UAAU,CAAC,CAAC;IAC9C,OAAO,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,KAAK,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AAClF,CAAC","sourcesContent":["import { Option, formatFixOptions } from './fix-option';\n\n/**\n * Thrown by ANY rule — in `ai-hook-rules` (edit-time) OR `code-rules` (build/CI-time) — to report a\n * failure from anywhere in its logic. Each engine wraps every rule in a per-rule try/catch, so a\n * thrown `RuleFailError` becomes one visible failure entry and the loop keeps going to the next rule;\n * a plain `Error` (a real bug) is caught the same way and surfaced too — one rule can never abort the\n * others.\n *\n * It is a STANDALONE `Error` — deliberately NOT an `InformAiError`. `InformAiError` is an AI-only\n * concept (it informs Claude Code); `code-rules` has no notion of \"AI\", so a shared rule-failure type\n * must not depend on it. Rules report failures with `RuleFailError`; `InformAiError` stays for\n * config/stdin/plumbing errors and the AI-facing guards path.\n *\n * Two audiences, one throw:\n * - `aiMessage` — what the AI sees in the ai-hook path (also `Error.message`).\n * - `humanMessage` — what a developer/CI sees in the code-rules console (defaults to `aiMessage`).\n *\n * `line`/`snippet`/`fixOptions` are optional context the ai-hook engine folds into its `Violation`.\n *\n * `fixOptions` is `readonly Option[]` — the SAME `Option` that `FixHint.fixOptions` carries, so there is\n * exactly ONE representation of \"the list of cures\" across both engines, and a build-time rule can mark\n * one cure `preferred` exactly like an edit-time rule can. It was `readonly string[]`; that spelling is\n * deleted and does not compile. NEVER hand-number the cures inside `aiMessage` — the framework\n * (`formatFixOptions`) owns the \"Fix Option N:\" numbering and the \"(preferred)\" tag.\n *\n * Constructor is positional to match this package's other data classes (`Violation`, `ResolvedConfig`)\n * and the project's classes-over-interfaces convention. Common throws:\n * throw new RuleFailError('no-any-unknown', 'Avoid `any` here — use `unknown`.', 42, 'const x: any');\n * throw new RuleFailError('max-file-lines', 'File exceeds the limit.', undefined, undefined,\n * [new Option('Split it into two modules', true), new Option('Move the helpers to a sibling file')]);\n */\nexport class RuleFailError extends Error {\n override cause?: Error;\n readonly ruleName: string;\n readonly aiMessage: string;\n readonly humanMessage: string;\n readonly line: number | undefined;\n readonly snippet: string | undefined;\n readonly fixOptions: readonly Option[];\n\n constructor(\n ruleName: string,\n aiMessage: string,\n line?: number,\n snippet?: string,\n fixOptions: readonly Option[] = [],\n humanMessage?: string,\n cause?: Error,\n ) {\n super(aiMessage);\n this.name = 'RuleFailError';\n this.ruleName = ruleName;\n this.aiMessage = aiMessage;\n this.humanMessage = humanMessage ?? aiMessage;\n this.line = line;\n this.snippet = snippet;\n this.fixOptions = fixOptions;\n this.cause = cause;\n }\n}\n\n/**\n * The AI-audience rendering of a thrown `RuleFailError`: its `aiMessage` plus its cures, numbered and\n * tagged by the framework. Used by the top-level handlers in `ai-hook-rules`.\n *\n * WHY it exists: every handler used to print `aiMessage`/`humanMessage` alone, so a `RuleFailError`\n * that escaped a per-rule catch reached the AI or the CI console with its `fixOptions` SILENTLY\n * DROPPED — the rule had said how to fix the problem and the renderer threw that away. One renderer per\n * audience means a cure cannot go missing depending on which catch caught the throw.\n */\n// webpieces-disable no-function-outside-class -- string formatter, sibling of formatFixOptions\nexport function renderRuleFailForAi(error: RuleFailError): string {\n return joinCures(error.aiMessage, error.fixOptions);\n}\n\n/** The developer/CI rendering: `humanMessage` (which defaults to `aiMessage`) plus the same cures. */\n// webpieces-disable no-function-outside-class -- string formatter, sibling of formatFixOptions\nexport function renderRuleFailForHuman(error: RuleFailError): string {\n return joinCures(error.humanMessage, error.fixOptions);\n}\n\n// webpieces-disable no-function-outside-class -- private helper of the two renderers above\nfunction joinCures(message: string, fixOptions: readonly Option[]): string {\n const rendered = formatFixOptions(fixOptions);\n return rendered.length === 0 ? message : `${message}\\n${rendered.join('\\n')}`;\n}\n"]}
|
package/src/skip-rule.d.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The skip decision. A CLASS, not an interface (CLAUDE.md rule 1), because it carries a THIRD fact
|
|
3
|
+
* beyond yes/no that a caller has to be able to read rather than read about in a log line.
|
|
4
|
+
*
|
|
5
|
+
* `hatchNotApplied` is that fact: a branch hatch WAS configured, and it could NOT apply — today only
|
|
6
|
+
* because HEAD is detached (a tag checkout, a `git bisect` step, a CI checkout of a merge ref). The rule
|
|
7
|
+
* is ENFORCED in that case, so there is nothing to report unless the rule then FAILS; when it does, its
|
|
8
|
+
* own RuleFailError is where this belongs, as context on why the hatch the config shows did not save it.
|
|
9
|
+
*
|
|
10
|
+
* It is deliberately NOT printed here. Everything in this framework throws to ONE place that renders per
|
|
11
|
+
* audience; a console write from a library function cannot be caught, re-rendered or asserted on.
|
|
12
|
+
*/
|
|
13
|
+
export declare class SkipRuleResult {
|
|
2
14
|
skip: boolean;
|
|
3
|
-
|
|
15
|
+
/** Why the rule IS being skipped. '' whenever `skip` is false. */
|
|
16
|
+
reason: string;
|
|
17
|
+
/** Why a CONFIGURED branch hatch did not apply, or '' when there is nothing to say. */
|
|
18
|
+
hatchNotApplied: string;
|
|
19
|
+
constructor(skip: boolean, reason?: string, hatchNotApplied?: string);
|
|
4
20
|
}
|
|
5
21
|
export declare function getCurrentBranch(): string;
|
|
6
|
-
export declare function shouldSkipRule(epoch: number | undefined,
|
|
22
|
+
export declare function shouldSkipRule(epoch: number | undefined, branchName: string | undefined | null): SkipRuleResult;
|
package/src/skip-rule.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SkipRuleResult = void 0;
|
|
3
4
|
exports.getCurrentBranch = getCurrentBranch;
|
|
4
5
|
exports.shouldSkipRule = shouldSkipRule;
|
|
6
|
+
const tslib_1 = require("tslib");
|
|
5
7
|
const child_process_1 = require("child_process");
|
|
8
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
9
|
const inform_ai_error_1 = require("./inform-ai-error");
|
|
10
|
+
const rule_fail_error_1 = require("./rule-fail-error");
|
|
11
|
+
const fix_option_1 = require("./fix-option");
|
|
7
12
|
const to_error_1 = require("./to-error");
|
|
13
|
+
// Universal "should this rule be skipped right now?" logic, shared by code-rules,
|
|
14
|
+
// ai-hook-rules and the Nx executors so every rule honors the same two escape
|
|
15
|
+
// hatches: turnOffRuleWhileOnBranch (skip while on a named branch) and
|
|
16
|
+
// turnOffRuleUntilEpoch (skip until an epoch passes).
|
|
17
|
+
/**
|
|
18
|
+
* The skip decision. A CLASS, not an interface (CLAUDE.md rule 1), because it carries a THIRD fact
|
|
19
|
+
* beyond yes/no that a caller has to be able to read rather than read about in a log line.
|
|
20
|
+
*
|
|
21
|
+
* `hatchNotApplied` is that fact: a branch hatch WAS configured, and it could NOT apply — today only
|
|
22
|
+
* because HEAD is detached (a tag checkout, a `git bisect` step, a CI checkout of a merge ref). The rule
|
|
23
|
+
* is ENFORCED in that case, so there is nothing to report unless the rule then FAILS; when it does, its
|
|
24
|
+
* own RuleFailError is where this belongs, as context on why the hatch the config shows did not save it.
|
|
25
|
+
*
|
|
26
|
+
* It is deliberately NOT printed here. Everything in this framework throws to ONE place that renders per
|
|
27
|
+
* audience; a console write from a library function cannot be caught, re-rendered or asserted on.
|
|
28
|
+
*/
|
|
29
|
+
class SkipRuleResult {
|
|
30
|
+
skip;
|
|
31
|
+
/** Why the rule IS being skipped. '' whenever `skip` is false. */
|
|
32
|
+
reason;
|
|
33
|
+
/** Why a CONFIGURED branch hatch did not apply, or '' when there is nothing to say. */
|
|
34
|
+
hatchNotApplied;
|
|
35
|
+
constructor(skip, reason = '', hatchNotApplied = '') {
|
|
36
|
+
this.skip = skip;
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
this.hatchNotApplied = hatchNotApplied;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.SkipRuleResult = SkipRuleResult;
|
|
8
42
|
// The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,
|
|
9
43
|
// CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main
|
|
10
44
|
// locally made this return "main" on a feature branch, which (a) mislabeled the main-sync cache and
|
|
@@ -19,6 +53,12 @@ const to_error_1 = require("./to-error");
|
|
|
19
53
|
// GITHUB_REF_NAME: on pull_request that is "<N>/merge", not a branch.)
|
|
20
54
|
// WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here
|
|
21
55
|
// (GitLab, CircleCI, Buildkite). Nobody sets it by accident.
|
|
56
|
+
//
|
|
57
|
+
// This getter answers "what branch am I on?" and NOTHING about whether that answer may be TRUSTED to
|
|
58
|
+
// unlock an escape hatch. That second question is asked by shouldSkipRule alone (see
|
|
59
|
+
// assertBranchIsTrustworthy) because this getter has callers — the main-sync cache label, merged-PR
|
|
60
|
+
// detection, code-rules' re-export of it — for which a fork's own branch name is a perfectly good
|
|
61
|
+
// answer, and making the getter itself throw would redden all of them.
|
|
22
62
|
function getCurrentBranch() {
|
|
23
63
|
const prBranch = process.env['GITHUB_HEAD_REF'];
|
|
24
64
|
if (prBranch)
|
|
@@ -36,40 +76,136 @@ function getCurrentBranch() {
|
|
|
36
76
|
throw new inform_ai_error_1.InformAiError(`Failed to determine current git branch: ${error.message}`, { cause: error });
|
|
37
77
|
}
|
|
38
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Refuse the branch hatch on a pull request whose head branch this repo does not own.
|
|
81
|
+
*
|
|
82
|
+
* The hole it closes: hatch names live in `webpieces.config.json`, which is COMMITTED and public. On a
|
|
83
|
+
* `pull_request` from a FORK the runner sets GITHUB_HEAD_REF to the FORK AUTHOR's branch name, so an
|
|
84
|
+
* outside contributor who names their branch after one of your hatches silently disables that rule on
|
|
85
|
+
* their PR — the one place you least want a rule off.
|
|
86
|
+
*
|
|
87
|
+
* It is answered with no network and no token, from `$GITHUB_EVENT_PATH` — a JSON file the runner writes
|
|
88
|
+
* before the job starts:
|
|
89
|
+
* - `pull_request_target` runs with the BASE repo's secrets against contributor-authored code, so it is
|
|
90
|
+
* untrusted unconditionally; there is nothing to compare.
|
|
91
|
+
* - `pull_request` is trusted only when the head repo's `full_name` EQUALS `GITHUB_REPOSITORY`.
|
|
92
|
+
*
|
|
93
|
+
* "Cannot tell" counts as untrusted — a `pull_request` run with no readable event file cannot make the
|
|
94
|
+
* comparison, and a hatch that cannot be proven to be yours must not fire.
|
|
95
|
+
*
|
|
96
|
+
* This is the ONE hard failure left in this module, and it is not the "no branch here" case (see
|
|
97
|
+
* shouldSkipRule, which enforces quietly when HEAD is detached). It is a hatch name that DOES resolve but
|
|
98
|
+
* is attacker-chosen — a security property, not a checkout that happens not to be editing.
|
|
99
|
+
*
|
|
100
|
+
* Called ONLY from inside `if (branchName)`. With turnOffRuleWhileOnBranch null — the overwhelmingly
|
|
101
|
+
* common value — nothing here runs and no fork PR is affected in any way.
|
|
102
|
+
*/
|
|
103
|
+
// webpieces-disable no-function-outside-class -- module-scope helper of the module-scope shouldSkipRule it serves; this whole module is functional by design (imported as free functions by code-rules, ai-hook-rules and the nx executors)
|
|
104
|
+
function assertBranchIsTrustworthy(branchName) {
|
|
105
|
+
const eventName = process.env['GITHUB_EVENT_NAME'];
|
|
106
|
+
if (eventName !== 'pull_request' && eventName !== 'pull_request_target')
|
|
107
|
+
return;
|
|
108
|
+
if (eventName === 'pull_request_target')
|
|
109
|
+
throw forkRefusal(branchName, 'pull_request_target run');
|
|
110
|
+
const headRepo = readHeadRepoFullName(branchName);
|
|
111
|
+
const thisRepo = process.env['GITHUB_REPOSITORY'] ?? '';
|
|
112
|
+
if (headRepo !== null && thisRepo !== '' && headRepo === thisRepo)
|
|
113
|
+
return;
|
|
114
|
+
throw forkRefusal(branchName, headRepo === null
|
|
115
|
+
? 'pull_request run with no readable $GITHUB_EVENT_PATH, so the head branch cannot be proven to belong to this repo'
|
|
116
|
+
: `pull_request run whose head branch lives in "${headRepo}", not in "${thisRepo}"`);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The head repo's `owner/name` from the runner's event file, or null when there is no such file.
|
|
120
|
+
*
|
|
121
|
+
* A file that exists but cannot be parsed is a different thing from one that is absent, and it throws:
|
|
122
|
+
* it means the runner wrote something this code does not understand, and silently downgrading that to
|
|
123
|
+
* "untrusted" would hide a real incompatibility behind a message about forks.
|
|
124
|
+
*/
|
|
125
|
+
// webpieces-disable no-function-outside-class -- module-scope helper, see assertBranchIsTrustworthy
|
|
126
|
+
function readHeadRepoFullName(branchName) {
|
|
127
|
+
const eventPath = process.env['GITHUB_EVENT_PATH'];
|
|
128
|
+
if (!eventPath || !fs.existsSync(eventPath))
|
|
129
|
+
return null;
|
|
130
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: a raw SyntaxError from the runner's event file would say nothing about hatches; this names the hatch, the file and the rule that stayed ON
|
|
131
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- see above
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
|
134
|
+
return parsed.pull_request?.head?.repo?.full_name ?? null;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const error = (0, to_error_1.toError)(err);
|
|
138
|
+
throw new rule_fail_error_1.RuleFailError('turnOffRuleWhileOnBranch', `turnOffRuleWhileOnBranch: "${branchName}" is configured and this is a pull_request run, so the ` +
|
|
139
|
+
`head repository must be checked before the hatch may fire — but the runner's event file ` +
|
|
140
|
+
`${eventPath} could not be read: ${error.message}`, undefined, undefined, [new fix_option_1.Option('Use turnOffRuleUntilEpoch instead — it is TIME based and needs no event file.', true),
|
|
141
|
+
new fix_option_1.Option('Or clear turnOffRuleWhileOnBranch (set it to null) so no trust check is needed at all.')], undefined, error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The fork refusal, as a `RuleFailError`.
|
|
146
|
+
*
|
|
147
|
+
* The cures are `fixOptions` — a LIST of `Option` — because the framework owns how they are labelled
|
|
148
|
+
* and numbered (`formatFixOptions` renders "Fix Option N:" and the "(preferred)" tag for BOTH engines).
|
|
149
|
+
* A hand-numbered "WORKAROUNDS: 1. … 2. …" string literal, which is what this used to be, is exactly
|
|
150
|
+
* the shape `Option` exists to prevent. `Option` lives HERE in `rules-config`, so the same class serves
|
|
151
|
+
* this build-time throw and `FixHint` in `ai-hook-rules` — one cure shape, one definition.
|
|
152
|
+
*
|
|
153
|
+
* `ruleName` is the HATCH, not the rule being evaluated: `shouldSkipRule` is handed only the two hatch
|
|
154
|
+
* values and does not know whose rule it is deciding for — and the thing that failed genuinely is the
|
|
155
|
+
* hatch, which cannot be honored here.
|
|
156
|
+
*/
|
|
157
|
+
// webpieces-disable no-function-outside-class -- module-scope helper, see assertBranchIsTrustworthy
|
|
158
|
+
function forkRefusal(branchName, what) {
|
|
159
|
+
return new rule_fail_error_1.RuleFailError('turnOffRuleWhileOnBranch', `turnOffRuleWhileOnBranch: "${branchName}" is configured, but this is a ${what}. The branch name ` +
|
|
160
|
+
`there is chosen by the PULL REQUEST AUTHOR, and hatch names are public (webpieces.config.json ` +
|
|
161
|
+
`is committed), so honoring the hatch would let anyone turn this rule off on their own PR just ` +
|
|
162
|
+
`by naming their branch after it. The rule is NOT skipped, and this fails loudly rather than ` +
|
|
163
|
+
`quietly enforcing a rule the config says is off.`, undefined, undefined, [
|
|
164
|
+
new fix_option_1.Option('If the rule must be off for outside contributions too, use turnOffRuleUntilEpoch — it is TIME ' +
|
|
165
|
+
'based, so it cannot be self-granted by naming a branch. Keep the date short: it is repo-wide ' +
|
|
166
|
+
'while it lasts, so it also shelters unrelated work landing in the same window.', true),
|
|
167
|
+
new fix_option_1.Option('Otherwise clear turnOffRuleWhileOnBranch (set it to null) and fix the findings on the ' +
|
|
168
|
+
'contributed branch like any other.'),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
39
171
|
function shouldSkipRule(epoch,
|
|
172
|
+
// The branch hatch is an EXACT branch name — compared with `===`, never a glob, regex or prefix.
|
|
173
|
+
// That is deliberate and is not to be "improved": the hatch turns a rule OFF, and a pattern lets one
|
|
174
|
+
// config line switch rules off on branches nobody enumerated when it was written. If two branches
|
|
175
|
+
// need the same rule off, that is two branches renamed to one hatch name, not one wildcard.
|
|
176
|
+
//
|
|
40
177
|
// null (the "no branch / always on" value of turnOffRuleWhileOnBranch) is treated exactly like
|
|
41
178
|
// undefined — no branch scoping. Only a non-empty branch name activates the branch hatch.
|
|
42
|
-
|
|
43
|
-
if (
|
|
179
|
+
branchName) {
|
|
180
|
+
if (branchName) {
|
|
181
|
+
// The fork gate fires ONLY inside `if (branchName)`. With turnOffRuleWhileOnBranch=null — the
|
|
182
|
+
// overwhelmingly common value — nothing below runs, so an outside contribution to a repo that
|
|
183
|
+
// configured no hatch is an ordinary run.
|
|
184
|
+
//
|
|
185
|
+
// A DETACHED HEAD is NOT an error here. The branch hatch exists to relax a rule while you EDIT on
|
|
186
|
+
// a branch; a tag checkout or a `git bisect` step is not editing (you cannot commit to a tag, and
|
|
187
|
+
// to edit you branch off it first). So when no branch can be resolved the hatch simply does not
|
|
188
|
+
// apply and THE RULE IS ENFORCED — whoever hits a violation then gets that rule's own message and
|
|
189
|
+
// cure, which is the useful output. WHY the hatch did not fire rides back on the result
|
|
190
|
+
// (SkipRuleResult.hatchNotApplied) rather than being printed, so a caller that does fail can fold
|
|
191
|
+
// it into its own error instead of a library writing to a console nobody can catch.
|
|
192
|
+
assertBranchIsTrustworthy(branchName);
|
|
44
193
|
const current = getCurrentBranch();
|
|
45
|
-
// ONLY inside `if (branchPattern)`. A detached HEAD with turnOffRuleWhileOnBranch=null (the
|
|
46
|
-
// overwhelmingly common value) is a non-event and must stay silent, or every tag build,
|
|
47
|
-
// bisect and `gh pr checkout --detach` starts failing for no reason. The fault reported here
|
|
48
|
-
// is "you asked for branch scoping where no branch exists", never "HEAD is detached".
|
|
49
194
|
if (current === 'HEAD' || current === '') {
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
`looks like. The hatch would silently NOT apply, so this fails now rather than passing ` +
|
|
53
|
-
`on your machine and failing in CI with an unrelated-looking error.\n\n` +
|
|
54
|
-
`WORKAROUNDS, in order of preference:\n` +
|
|
55
|
-
` 1. Upgrade to a webpieces that reads GITHUB_HEAD_REF (no workflow change needed).\n` +
|
|
56
|
-
` 2. Set WEBPIECES_BRANCH in the workflow so the branch can be resolved.\n` +
|
|
57
|
-
` 3. Use turnOffRuleUntilEpoch instead — it is TIME based, so it survives any checkout ` +
|
|
58
|
-
`including a detached one, and it is the only hatch that works in CI today. Set it to a ` +
|
|
59
|
-
`SHORT date (a few days): unlike branch scoping it is repo-wide while it lasts, so it ` +
|
|
60
|
-
`also shelters unrelated work that lands in the same window.`);
|
|
195
|
+
return new SkipRuleResult(false, '', `turnOffRuleWhileOnBranch: "${branchName}" did not apply — HEAD is detached, so there is no ` +
|
|
196
|
+
`branch to match (a tag checkout, a git bisect step, or a CI checkout of a merge ref).`);
|
|
61
197
|
}
|
|
62
|
-
if (current ===
|
|
63
|
-
return
|
|
198
|
+
if (current === branchName) {
|
|
199
|
+
return new SkipRuleResult(true, `on branch "${branchName}"`);
|
|
64
200
|
}
|
|
65
201
|
}
|
|
66
202
|
if (epoch !== undefined) {
|
|
67
203
|
const nowSeconds = Date.now() / 1000;
|
|
68
204
|
if (nowSeconds < epoch) {
|
|
69
205
|
const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];
|
|
70
|
-
return
|
|
206
|
+
return new SkipRuleResult(true, `turnOffRuleUntilEpoch active, expires: ${expiresDate}`);
|
|
71
207
|
}
|
|
72
208
|
}
|
|
73
|
-
return
|
|
209
|
+
return new SkipRuleResult(false);
|
|
74
210
|
}
|
|
75
211
|
//# sourceMappingURL=skip-rule.js.map
|
package/src/skip-rule.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skip-rule.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/skip-rule.ts"],"names":[],"mappings":";;AA6BA,4CAeC;AAED,wCAuCC;AArFD,iDAAyC;AAEzC,uDAAkD;AAClD,yCAAqC;AAYrC,4FAA4F;AAC5F,gGAAgG;AAChG,oGAAoG;AACpG,2EAA2E;AAC3E,EAAE;AACF,qGAAqG;AACrG,qGAAqG;AACrG,qGAAqG;AACrG,+CAA+C;AAC/C,oGAAoG;AACpG,mGAAmG;AACnG,4FAA4F;AAC5F,4FAA4F;AAC5F,kFAAkF;AAClF,SAAgB,gBAAgB;IAC5B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAChD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAE9B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAE9B,wHAAwH;IACxH,0IAA0I;IAC1I,IAAI,CAAC;QACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACpF,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,IAAI,+BAAa,CAAC,2CAA2C,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC1G,CAAC;AACL,CAAC;AAED,SAAgB,cAAc,CAC1B,KAAyB;AACzB,+FAA+F;AAC/F,0FAA0F;AAC1F,aAAwC;IAExC,IAAI,aAAa,EAAE,CAAC;QAChB,MAAM,OAAO,GAAG,gBAAgB,EAAE,CAAC;QACnC,4FAA4F;QAC5F,wFAAwF;QACxF,6FAA6F;QAC7F,sFAAsF;QACtF,IAAI,OAAO,KAAK,MAAM,IAAI,OAAO,KAAK,EAAE,EAAE,CAAC;YACvC,MAAM,IAAI,+BAAa,CACnB,8BAA8B,aAAa,0CAA0C;gBACjF,sFAAsF;gBACtF,wFAAwF;gBACxF,wEAAwE;gBACxE,wCAAwC;gBACxC,uFAAuF;gBACvF,4EAA4E;gBAC5E,yFAAyF;gBACzF,yFAAyF;gBACzF,uFAAuF;gBACvF,6DAA6D,CACpE,CAAC;QACN,CAAC;QACD,IAAI,OAAO,KAAK,aAAa,EAAE,CAAC;YAC5B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,aAAa,GAAG,EAAE,CAAC;QAClE,CAAC;IACL,CAAC;IACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACtB,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;QACrC,IAAI,UAAU,GAAG,KAAK,EAAE,CAAC;YACrB,MAAM,WAAW,GAAG,IAAI,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;YACvE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,0CAA0C,WAAW,EAAE,EAAE,CAAC;QAC3F,CAAC;IACL,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC3B,CAAC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { InformAiError } from './inform-ai-error';\nimport { toError } from './to-error';\n\n// Universal \"should this rule be skipped right now?\" logic, shared by code-rules,\n// ai-hook-rules and the Nx executors so every rule honors the same two escape\n// hatches: turnOffRuleWhileOnBranch (skip while on a named branch) and\n// turnOffRuleUntilEpoch (skip until an epoch passes).\n\nexport interface SkipRuleResult {\n skip: boolean;\n reason?: string;\n}\n\n// The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,\n// CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main\n// locally made this return \"main\" on a feature branch, which (a) mislabeled the main-sync cache and\n// (b) silently disabled merged-PR detection (detectMergedPr skips \"main\").\n//\n// The two vars below are NOT that. They are consulted BEFORE git because git cannot answer at all in\n// the case they cover — a `pull_request` checkout leaves HEAD detached on refs/pull/<N>/merge, where\n// `git rev-parse --abbrev-ref HEAD` returns the literal string \"HEAD\" and no branch hatch can match.\n// Neither can go stale the way GIT_BRANCH did:\n// GITHUB_HEAD_REF — set by the GitHub runner ONLY on pull_request/pull_request_target, and it IS\n// the source branch name. Absent on push, so the fallthrough stays safe. (Not\n// GITHUB_REF_NAME: on pull_request that is \"<N>/merge\", not a branch.)\n// WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here\n// (GitLab, CircleCI, Buildkite). Nobody sets it by accident.\nexport function getCurrentBranch(): string {\n const prBranch = process.env['GITHUB_HEAD_REF'];\n if (prBranch) return prBranch;\n\n const override = process.env['WEBPIECES_BRANCH'];\n if (override) return override;\n\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Failed to determine current git branch: ${error.message}`, { cause: error });\n }\n}\n\nexport function shouldSkipRule(\n epoch: number | undefined,\n // null (the \"no branch / always on\" value of turnOffRuleWhileOnBranch) is treated exactly like\n // undefined — no branch scoping. Only a non-empty branch name activates the branch hatch.\n branchPattern: string | undefined | null\n): SkipRuleResult {\n if (branchPattern) {\n const current = getCurrentBranch();\n // ONLY inside `if (branchPattern)`. A detached HEAD with turnOffRuleWhileOnBranch=null (the\n // overwhelmingly common value) is a non-event and must stay silent, or every tag build,\n // bisect and `gh pr checkout --detach` starts failing for no reason. The fault reported here\n // is \"you asked for branch scoping where no branch exists\", never \"HEAD is detached\".\n if (current === 'HEAD' || current === '') {\n throw new InformAiError(\n `turnOffRuleWhileOnBranch: \"${branchPattern}\" is configured, but the current branch ` +\n `cannot be determined — HEAD is detached, which is what a CI checkout of a merge ref ` +\n `looks like. The hatch would silently NOT apply, so this fails now rather than passing ` +\n `on your machine and failing in CI with an unrelated-looking error.\\n\\n` +\n `WORKAROUNDS, in order of preference:\\n` +\n ` 1. Upgrade to a webpieces that reads GITHUB_HEAD_REF (no workflow change needed).\\n` +\n ` 2. Set WEBPIECES_BRANCH in the workflow so the branch can be resolved.\\n` +\n ` 3. Use turnOffRuleUntilEpoch instead — it is TIME based, so it survives any checkout ` +\n `including a detached one, and it is the only hatch that works in CI today. Set it to a ` +\n `SHORT date (a few days): unlike branch scoping it is repo-wide while it lasts, so it ` +\n `also shelters unrelated work that lands in the same window.`\n );\n }\n if (current === branchPattern) {\n return { skip: true, reason: `on branch \"${branchPattern}\"` };\n }\n }\n if (epoch !== undefined) {\n const nowSeconds = Date.now() / 1000;\n if (nowSeconds < epoch) {\n const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];\n return { skip: true, reason: `turnOffRuleUntilEpoch active, expires: ${expiresDate}` };\n }\n }\n return { skip: false };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"skip-rule.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/skip-rule.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\n\nimport { InformAiError } from './inform-ai-error';\nimport { RuleFailError } from './rule-fail-error';\nimport { Option } from './fix-option';\nimport { toError } from './to-error';\n\n// Universal \"should this rule be skipped right now?\" logic, shared by code-rules,\n// ai-hook-rules and the Nx executors so every rule honors the same two escape\n// hatches: turnOffRuleWhileOnBranch (skip while on a named branch) and\n// turnOffRuleUntilEpoch (skip until an epoch passes).\n\n/**\n * The skip decision. A CLASS, not an interface (CLAUDE.md rule 1), because it carries a THIRD fact\n * beyond yes/no that a caller has to be able to read rather than read about in a log line.\n *\n * `hatchNotApplied` is that fact: a branch hatch WAS configured, and it could NOT apply — today only\n * because HEAD is detached (a tag checkout, a `git bisect` step, a CI checkout of a merge ref). The rule\n * is ENFORCED in that case, so there is nothing to report unless the rule then FAILS; when it does, its\n * own RuleFailError is where this belongs, as context on why the hatch the config shows did not save it.\n *\n * It is deliberately NOT printed here. Everything in this framework throws to ONE place that renders per\n * audience; a console write from a library function cannot be caught, re-rendered or asserted on.\n */\nexport class SkipRuleResult {\n skip: boolean;\n /** Why the rule IS being skipped. '' whenever `skip` is false. */\n reason: string;\n /** Why a CONFIGURED branch hatch did not apply, or '' when there is nothing to say. */\n hatchNotApplied: string;\n\n constructor(skip: boolean, reason: string = '', hatchNotApplied: string = '') {\n this.skip = skip;\n this.reason = reason;\n this.hatchNotApplied = hatchNotApplied;\n }\n}\n\n// The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,\n// CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main\n// locally made this return \"main\" on a feature branch, which (a) mislabeled the main-sync cache and\n// (b) silently disabled merged-PR detection (detectMergedPr skips \"main\").\n//\n// The two vars below are NOT that. They are consulted BEFORE git because git cannot answer at all in\n// the case they cover — a `pull_request` checkout leaves HEAD detached on refs/pull/<N>/merge, where\n// `git rev-parse --abbrev-ref HEAD` returns the literal string \"HEAD\" and no branch hatch can match.\n// Neither can go stale the way GIT_BRANCH did:\n// GITHUB_HEAD_REF — set by the GitHub runner ONLY on pull_request/pull_request_target, and it IS\n// the source branch name. Absent on push, so the fallthrough stays safe. (Not\n// GITHUB_REF_NAME: on pull_request that is \"<N>/merge\", not a branch.)\n// WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here\n// (GitLab, CircleCI, Buildkite). Nobody sets it by accident.\n//\n// This getter answers \"what branch am I on?\" and NOTHING about whether that answer may be TRUSTED to\n// unlock an escape hatch. That second question is asked by shouldSkipRule alone (see\n// assertBranchIsTrustworthy) because this getter has callers — the main-sync cache label, merged-PR\n// detection, code-rules' re-export of it — for which a fork's own branch name is a perfectly good\n// answer, and making the getter itself throw would redden all of them.\nexport function getCurrentBranch(): string {\n const prBranch = process.env['GITHUB_HEAD_REF'];\n if (prBranch) return prBranch;\n\n const override = process.env['WEBPIECES_BRANCH'];\n if (override) return override;\n\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Failed to determine current git branch: ${error.message}`, { cause: error });\n }\n}\n\n// The slice of the GitHub `pull_request` event payload that says WHO OWNS the head branch. Only the\n// one field below is read; everything else in that file is ignored.\ninterface RawPullRequestEvent {\n pull_request?: {\n head?: {\n repo?: {\n full_name?: string;\n };\n };\n };\n}\n\n/**\n * Refuse the branch hatch on a pull request whose head branch this repo does not own.\n *\n * The hole it closes: hatch names live in `webpieces.config.json`, which is COMMITTED and public. On a\n * `pull_request` from a FORK the runner sets GITHUB_HEAD_REF to the FORK AUTHOR's branch name, so an\n * outside contributor who names their branch after one of your hatches silently disables that rule on\n * their PR — the one place you least want a rule off.\n *\n * It is answered with no network and no token, from `$GITHUB_EVENT_PATH` — a JSON file the runner writes\n * before the job starts:\n * - `pull_request_target` runs with the BASE repo's secrets against contributor-authored code, so it is\n * untrusted unconditionally; there is nothing to compare.\n * - `pull_request` is trusted only when the head repo's `full_name` EQUALS `GITHUB_REPOSITORY`.\n *\n * \"Cannot tell\" counts as untrusted — a `pull_request` run with no readable event file cannot make the\n * comparison, and a hatch that cannot be proven to be yours must not fire.\n *\n * This is the ONE hard failure left in this module, and it is not the \"no branch here\" case (see\n * shouldSkipRule, which enforces quietly when HEAD is detached). It is a hatch name that DOES resolve but\n * is attacker-chosen — a security property, not a checkout that happens not to be editing.\n *\n * Called ONLY from inside `if (branchName)`. With turnOffRuleWhileOnBranch null — the overwhelmingly\n * common value — nothing here runs and no fork PR is affected in any way.\n */\n// webpieces-disable no-function-outside-class -- module-scope helper of the module-scope shouldSkipRule it serves; this whole module is functional by design (imported as free functions by code-rules, ai-hook-rules and the nx executors)\nfunction assertBranchIsTrustworthy(branchName: string): void {\n const eventName = process.env['GITHUB_EVENT_NAME'];\n if (eventName !== 'pull_request' && eventName !== 'pull_request_target') return;\n if (eventName === 'pull_request_target') throw forkRefusal(branchName, 'pull_request_target run');\n\n const headRepo = readHeadRepoFullName(branchName);\n const thisRepo = process.env['GITHUB_REPOSITORY'] ?? '';\n if (headRepo !== null && thisRepo !== '' && headRepo === thisRepo) return;\n throw forkRefusal(branchName, headRepo === null\n ? 'pull_request run with no readable $GITHUB_EVENT_PATH, so the head branch cannot be proven to belong to this repo'\n : `pull_request run whose head branch lives in \"${headRepo}\", not in \"${thisRepo}\"`);\n}\n\n/**\n * The head repo's `owner/name` from the runner's event file, or null when there is no such file.\n *\n * A file that exists but cannot be parsed is a different thing from one that is absent, and it throws:\n * it means the runner wrote something this code does not understand, and silently downgrading that to\n * \"untrusted\" would hide a real incompatibility behind a message about forks.\n */\n// webpieces-disable no-function-outside-class -- module-scope helper, see assertBranchIsTrustworthy\nfunction readHeadRepoFullName(branchName: string): string | null {\n const eventPath = process.env['GITHUB_EVENT_PATH'];\n if (!eventPath || !fs.existsSync(eventPath)) return null;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: a raw SyntaxError from the runner's event file would say nothing about hatches; this names the hatch, the file and the rule that stayed ON\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- see above\n try {\n const parsed = JSON.parse(fs.readFileSync(eventPath, 'utf8')) as RawPullRequestEvent;\n return parsed.pull_request?.head?.repo?.full_name ?? null;\n } catch (err: unknown) {\n const error = toError(err);\n throw new RuleFailError(\n 'turnOffRuleWhileOnBranch',\n `turnOffRuleWhileOnBranch: \"${branchName}\" is configured and this is a pull_request run, so the ` +\n `head repository must be checked before the hatch may fire — but the runner's event file ` +\n `${eventPath} could not be read: ${error.message}`,\n undefined,\n undefined,\n [new Option('Use turnOffRuleUntilEpoch instead — it is TIME based and needs no event file.', true),\n new Option('Or clear turnOffRuleWhileOnBranch (set it to null) so no trust check is needed at all.')],\n undefined,\n error);\n }\n}\n\n/**\n * The fork refusal, as a `RuleFailError`.\n *\n * The cures are `fixOptions` — a LIST of `Option` — because the framework owns how they are labelled\n * and numbered (`formatFixOptions` renders \"Fix Option N:\" and the \"(preferred)\" tag for BOTH engines).\n * A hand-numbered \"WORKAROUNDS: 1. … 2. …\" string literal, which is what this used to be, is exactly\n * the shape `Option` exists to prevent. `Option` lives HERE in `rules-config`, so the same class serves\n * this build-time throw and `FixHint` in `ai-hook-rules` — one cure shape, one definition.\n *\n * `ruleName` is the HATCH, not the rule being evaluated: `shouldSkipRule` is handed only the two hatch\n * values and does not know whose rule it is deciding for — and the thing that failed genuinely is the\n * hatch, which cannot be honored here.\n */\n// webpieces-disable no-function-outside-class -- module-scope helper, see assertBranchIsTrustworthy\nfunction forkRefusal(branchName: string, what: string): RuleFailError {\n return new RuleFailError(\n 'turnOffRuleWhileOnBranch',\n `turnOffRuleWhileOnBranch: \"${branchName}\" is configured, but this is a ${what}. The branch name ` +\n `there is chosen by the PULL REQUEST AUTHOR, and hatch names are public (webpieces.config.json ` +\n `is committed), so honoring the hatch would let anyone turn this rule off on their own PR just ` +\n `by naming their branch after it. The rule is NOT skipped, and this fails loudly rather than ` +\n `quietly enforcing a rule the config says is off.`,\n undefined,\n undefined,\n [\n new Option(\n 'If the rule must be off for outside contributions too, use turnOffRuleUntilEpoch — it is TIME ' +\n 'based, so it cannot be self-granted by naming a branch. Keep the date short: it is repo-wide ' +\n 'while it lasts, so it also shelters unrelated work landing in the same window.',\n true,\n ),\n new Option(\n 'Otherwise clear turnOffRuleWhileOnBranch (set it to null) and fix the findings on the ' +\n 'contributed branch like any other.',\n ),\n ],\n );\n}\n\nexport function shouldSkipRule(\n epoch: number | undefined,\n // The branch hatch is an EXACT branch name — compared with `===`, never a glob, regex or prefix.\n // That is deliberate and is not to be \"improved\": the hatch turns a rule OFF, and a pattern lets one\n // config line switch rules off on branches nobody enumerated when it was written. If two branches\n // need the same rule off, that is two branches renamed to one hatch name, not one wildcard.\n //\n // null (the \"no branch / always on\" value of turnOffRuleWhileOnBranch) is treated exactly like\n // undefined — no branch scoping. Only a non-empty branch name activates the branch hatch.\n branchName: string | undefined | null\n): SkipRuleResult {\n if (branchName) {\n // The fork gate fires ONLY inside `if (branchName)`. With turnOffRuleWhileOnBranch=null — the\n // overwhelmingly common value — nothing below runs, so an outside contribution to a repo that\n // configured no hatch is an ordinary run.\n //\n // A DETACHED HEAD is NOT an error here. The branch hatch exists to relax a rule while you EDIT on\n // a branch; a tag checkout or a `git bisect` step is not editing (you cannot commit to a tag, and\n // to edit you branch off it first). So when no branch can be resolved the hatch simply does not\n // apply and THE RULE IS ENFORCED — whoever hits a violation then gets that rule's own message and\n // cure, which is the useful output. WHY the hatch did not fire rides back on the result\n // (SkipRuleResult.hatchNotApplied) rather than being printed, so a caller that does fail can fold\n // it into its own error instead of a library writing to a console nobody can catch.\n assertBranchIsTrustworthy(branchName);\n const current = getCurrentBranch();\n if (current === 'HEAD' || current === '') {\n return new SkipRuleResult(false, '',\n `turnOffRuleWhileOnBranch: \"${branchName}\" did not apply — HEAD is detached, so there is no ` +\n `branch to match (a tag checkout, a git bisect step, or a CI checkout of a merge ref).`);\n }\n if (current === branchName) {\n return new SkipRuleResult(true, `on branch \"${branchName}\"`);\n }\n }\n if (epoch !== undefined) {\n const nowSeconds = Date.now() / 1000;\n if (nowSeconds < epoch) {\n const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];\n return new SkipRuleResult(true, `turnOffRuleUntilEpoch active, expires: ${expiresDate}`);\n }\n }\n return new SkipRuleResult(false);\n}\n"]}
|
package/src/validate-config.d.ts
CHANGED
|
@@ -2,6 +2,18 @@ import { validateChecklistsSection } from './pr-gate-section-validators';
|
|
|
2
2
|
export { validateChecklistsSection };
|
|
3
3
|
export { allRuleNames } from './rule-schemas';
|
|
4
4
|
export { recommendedSeedMode, recommendedSeedModeFor, seedEntryForRule } from './seed-entry';
|
|
5
|
+
/**
|
|
6
|
+
* The longest a `turnOffRuleUntilEpoch` may reach into the future: ONE WEEK. Poke it again next week to
|
|
7
|
+
* extend — that weekly re-set IS the intended workflow, not a workaround: it is what makes a rule that is
|
|
8
|
+
* off on purpose stay a decision somebody keeps making, instead of one nobody remembers.
|
|
9
|
+
*
|
|
10
|
+
* DELIBERATELY ASYMMETRIC with turnOffRuleWhileOnBranch, which has NO cap and must not get one. The epoch
|
|
11
|
+
* hatch is REPO-WIDE while it lasts, so a long window shelters every unrelated change that lands inside it
|
|
12
|
+
* (a fleet repo was found with max-file-lines/max-method-lines switched off until 2026-10-01 — 43 days
|
|
13
|
+
* out, six times this cap). The branch hatch fires only on ONE exact branch name — a big refactor
|
|
14
|
+
* legitimately runs 35+ days, and capping that would only interrupt the one branch that opted in.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAX_TURN_OFF_EPOCH_DAYS = 7;
|
|
5
17
|
export declare function validateWebpiecesConfig(rawRules: Record<string, Record<string, unknown>>, hasCustomRulesDir?: boolean): string[];
|
|
6
18
|
/**
|
|
7
19
|
* Validate the top-level `pr-gate` section. It is REQUIRED (a client that opts out sets mode "OFF").
|