pr-checkmate 4.0.1 → 4.1.1

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.
@@ -20,6 +20,13 @@ interface PackManifest {
20
20
  * package" and the check into a permanent skip.
21
21
  */
22
22
  export declare function pickManifest(parsed: unknown): PackManifest | null;
23
+ type PackResult = {
24
+ manifest: PackManifest;
25
+ } | {
26
+ failure: QueryFailure;
27
+ };
28
+ /** Describes a package as `npm pack` would; swapped out by tests that must not reach a registry. */
29
+ export type PackFn = (cwd: string, spec?: string, registry?: string) => Promise<PackResult>;
23
30
  /** Why we could not read a published version. */
24
31
  type QueryFailure = 'absent' | 'unauthenticated' | 'unreadable';
25
32
  /**
@@ -54,5 +61,6 @@ export declare function classifyQueryFailure(stderr: string): QueryFailure;
54
61
  * WASM binary landed — and a red build for it would be wrong. `severity:
55
62
  * { "Package Size": "error" }` makes it a gate for a repository that wants one.
56
63
  */
64
+ export declare function createPackageSizeCheck(pack?: PackFn): Check;
57
65
  export declare const packageSizeCheck: Check;
58
66
  export {};
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.packageSizeCheck = void 0;
7
7
  exports.pickManifest = pickManifest;
8
8
  exports.classifyQueryFailure = classifyQueryFailure;
9
+ exports.createPackageSizeCheck = createPackageSizeCheck;
9
10
  const node_fs_1 = __importDefault(require("node:fs"));
10
11
  const node_path_1 = __importDefault(require("node:path"));
11
12
  const execa_js_1 = require("../../../utils/execa.js");
@@ -16,6 +17,13 @@ const findings_js_1 = require("../findings.js");
16
17
  const DEFAULT_MAX_GROWTH = 20;
17
18
  /** How many of the files behind the growth are named. */
18
19
  const NAMED_FILES = 5;
20
+ /**
21
+ * How long one registry request may take, and how long the whole `npm pack` may
22
+ * run. A registry that does not answer must end as "could not read", not as a
23
+ * check that holds the report for npm's default retry schedule.
24
+ */
25
+ const NPM_FETCH_TIMEOUT_MS = 10000;
26
+ const NPM_TIMEOUT_MS = 30000;
19
27
  /**
20
28
  * The manifest out of whatever `npm pack --json` printed.
21
29
  *
@@ -59,9 +67,11 @@ async function packManifest(cwd, spec, registry) {
59
67
  '--dry-run',
60
68
  '--json',
61
69
  '--ignore-scripts',
70
+ '--fetch-retries=0',
71
+ `--fetch-timeout=${NPM_FETCH_TIMEOUT_MS}`,
62
72
  ...(registry ? ['--registry', registry] : []),
63
73
  ];
64
- const result = await (0, execa_js_1.execa)('npm', args, { cwd, reject: false });
74
+ const result = await (0, execa_js_1.execa)('npm', args, { cwd, reject: false, timeout: NPM_TIMEOUT_MS });
65
75
  if (result.exitCode !== 0)
66
76
  return { failure: classifyQueryFailure(result.stderr ?? '') };
67
77
  try {
@@ -154,66 +164,69 @@ function published(cwd) {
154
164
  * WASM binary landed — and a red build for it would be wrong. `severity:
155
165
  * { "Package Size": "error" }` makes it a gate for a repository that wants one.
156
166
  */
157
- exports.packageSizeCheck = {
158
- name: 'Package Size',
159
- phase: 'informational',
160
- weight: 'heavy',
161
- applies(ctx) {
162
- if (ctx.config.packageSize?.enabled === false)
163
- return false;
164
- // A private package is never published, so there is nothing to compare with
165
- // and no consumer to pay for the growth.
166
- return published(ctx.cwd) !== null;
167
- },
168
- async run(ctx) {
169
- const target = published(ctx.cwd);
170
- if (!target)
171
- return (0, types_js_1.skip)('no publishable package.json');
172
- const tag = ctx.config.packageSize?.compareWith ?? 'latest';
173
- const maxGrowth = ctx.config.packageSize?.maxGrowthPercent ?? DEFAULT_MAX_GROWTH;
174
- const spec = `${target.name}@${tag}`;
175
- const local = await packManifest(ctx.cwd);
176
- if (!('manifest' in local))
177
- return (0, types_js_1.skip)('npm could not describe this package');
178
- const current = local.manifest;
179
- // Never a pass: a green row over a comparison that did not happen is the
180
- // mistake of ticket 072. And never one reason standing in for three — the
181
- // three are different situations for the reader, and only one of them is a
182
- // fact about the package rather than about the run.
183
- const remote = await packManifest(ctx.cwd, spec, target.registry);
184
- if (!('manifest' in remote)) {
185
- // The fact, and nothing past it. The remedy for a private registry is three
186
- // lines in a workflow — `setup-node` with `registry-url`, a scope, and
187
- // `NODE_AUTH_TOKEN`, because npm reads the token through a reference in
188
- // `.npmrc` — and instructions that do not fit cannot be followed. They
189
- // belong on the check's page, which the report already links to.
190
- //
191
- // The package name is left out on purpose: the reader owns it. The host is
192
- // not, because it is the one part they cannot infer — and the difference
193
- // between "we asked the wrong registry" and "we were refused" is exactly
194
- // what this row got wrong before.
195
- // Named only when we know it. Without `publishConfig.registry` npm used
196
- // whatever the client's own config points at, and we do not know what that
197
- // was — "at the registry" would be filler dressed up as information.
198
- const host = target.registry ? new URL(target.registry).host : null;
199
- const at = host ? ` at ${host}` : '';
200
- const reason = {
201
- absent: `nothing published under this name${at} yet`,
202
- unauthenticated: `no credentials for ${host ?? 'the registry'}`,
203
- unreadable: `could not read the published version${at}`,
204
- }[remote.failure];
205
- return (0, types_js_1.skip)(reason);
206
- }
207
- const previous = remote.manifest;
208
- const delta = current.unpackedSize - previous.unpackedSize;
209
- const growth = previous.unpackedSize > 0 ? (delta / previous.unpackedSize) * 100 : 0;
210
- const summary = `${mb(previous.unpackedSize)} → ${mb(current.unpackedSize)} ` +
211
- `(${growth >= 0 ? '+' : ''}${growth.toFixed(1)}%) vs ${previous.version}`;
212
- if (growth <= maxGrowth) {
213
- index_js_1.logger.info(`[Package Size]: ✅ ${summary}`);
214
- return (0, types_js_1.pass)(summary);
215
- }
216
- (0, findings_js_1.logSample)('Package Size', `${summary}:`, growthByFile(previous.files, current.files));
217
- return (0, types_js_1.warn)(summary);
218
- },
219
- };
167
+ function createPackageSizeCheck(pack = packManifest) {
168
+ return {
169
+ name: 'Package Size',
170
+ phase: 'informational',
171
+ weight: 'heavy',
172
+ applies(ctx) {
173
+ if (ctx.config.packageSize?.enabled === false)
174
+ return false;
175
+ // A private package is never published, so there is nothing to compare with
176
+ // and no consumer to pay for the growth.
177
+ return published(ctx.cwd) !== null;
178
+ },
179
+ async run(ctx) {
180
+ const target = published(ctx.cwd);
181
+ if (!target)
182
+ return (0, types_js_1.skip)('no publishable package.json');
183
+ const tag = ctx.config.packageSize?.compareWith ?? 'latest';
184
+ const maxGrowth = ctx.config.packageSize?.maxGrowthPercent ?? DEFAULT_MAX_GROWTH;
185
+ const spec = `${target.name}@${tag}`;
186
+ const local = await pack(ctx.cwd);
187
+ if (!('manifest' in local))
188
+ return (0, types_js_1.skip)('npm could not describe this package');
189
+ const current = local.manifest;
190
+ // Never a pass: a green row over a comparison that did not happen is the
191
+ // mistake of ticket 072. And never one reason standing in for three — the
192
+ // three are different situations for the reader, and only one of them is a
193
+ // fact about the package rather than about the run.
194
+ const remote = await pack(ctx.cwd, spec, target.registry);
195
+ if (!('manifest' in remote)) {
196
+ // The fact, and nothing past it. The remedy for a private registry is three
197
+ // lines in a workflow — `setup-node` with `registry-url`, a scope, and
198
+ // `NODE_AUTH_TOKEN`, because npm reads the token through a reference in
199
+ // `.npmrc` — and instructions that do not fit cannot be followed. They
200
+ // belong on the check's page, which the report already links to.
201
+ //
202
+ // The package name is left out on purpose: the reader owns it. The host is
203
+ // not, because it is the one part they cannot infer — and the difference
204
+ // between "we asked the wrong registry" and "we were refused" is exactly
205
+ // what this row got wrong before.
206
+ // Named only when we know it. Without `publishConfig.registry` npm used
207
+ // whatever the client's own config points at, and we do not know what that
208
+ // was — "at the registry" would be filler dressed up as information.
209
+ const host = target.registry ? new URL(target.registry).host : null;
210
+ const at = host ? ` at ${host}` : '';
211
+ const reason = {
212
+ absent: `nothing published under this name${at} yet`,
213
+ unauthenticated: `no credentials for ${host ?? 'the registry'}`,
214
+ unreadable: `could not read the published version${at}`,
215
+ }[remote.failure];
216
+ return (0, types_js_1.skip)(reason);
217
+ }
218
+ const previous = remote.manifest;
219
+ const delta = current.unpackedSize - previous.unpackedSize;
220
+ const growth = previous.unpackedSize > 0 ? (delta / previous.unpackedSize) * 100 : 0;
221
+ const summary = `${mb(previous.unpackedSize)} → ${mb(current.unpackedSize)} ` +
222
+ `(${growth >= 0 ? '+' : ''}${growth.toFixed(1)}%) vs ${previous.version}`;
223
+ if (growth <= maxGrowth) {
224
+ index_js_1.logger.info(`[Package Size]: ✅ ${summary}`);
225
+ return (0, types_js_1.pass)(summary);
226
+ }
227
+ (0, findings_js_1.logSample)('Package Size', `${summary}:`, growthByFile(previous.files, current.files));
228
+ return (0, types_js_1.warn)(summary);
229
+ },
230
+ };
231
+ }
232
+ exports.packageSizeCheck = createPackageSizeCheck();
@@ -1,3 +1,4 @@
1
+ import { Document, LineCounter } from 'yaml';
1
2
  import { Check } from '../../types.js';
2
3
  /**
3
4
  * True when a parser problem describes something actually wrong with the file —
@@ -6,6 +7,20 @@ import { Check } from '../../types.js';
6
7
  export declare function isFileDefect(problem: {
7
8
  code?: string;
8
9
  }): boolean;
10
+ /**
11
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
12
+ *
13
+ * The parser does not treat this as a parse error: it builds the alias node and only
14
+ * throws when the document is converted to a JS value, which is exactly what a CI
15
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
16
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
17
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
18
+ */
19
+ export declare function unresolvedAliases(doc: Document.Parsed, lineCounter: LineCounter): {
20
+ name: string;
21
+ line: number;
22
+ col: number;
23
+ }[];
9
24
  /**
10
25
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
11
26
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.yamlLintCheck = void 0;
7
7
  exports.isFileDefect = isFileDefect;
8
+ exports.unresolvedAliases = unresolvedAliases;
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const yaml_1 = require("yaml");
@@ -38,6 +39,27 @@ const NON_DEFECT_CODES = new Set(['TAG_RESOLVE_FAILED']);
38
39
  function isFileDefect(problem) {
39
40
  return problem.code === undefined || !NON_DEFECT_CODES.has(problem.code);
40
41
  }
42
+ /**
43
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
44
+ *
45
+ * The parser does not treat this as a parse error: it builds the alias node and only
46
+ * throws when the document is converted to a JS value, which is exactly what a CI
47
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
48
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
49
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
50
+ */
51
+ function unresolvedAliases(doc, lineCounter) {
52
+ const found = [];
53
+ (0, yaml_1.visit)(doc, {
54
+ Alias(_key, node) {
55
+ if (node.resolve(doc) !== undefined)
56
+ return;
57
+ const { line, col } = lineCounter.linePos(node.range?.[0] ?? 0);
58
+ found.push({ name: node.source, line, col });
59
+ },
60
+ });
61
+ return found;
62
+ }
41
63
  /**
42
64
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
43
65
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -81,8 +103,18 @@ exports.yamlLintCheck = {
81
103
  }
82
104
  // parseAllDocuments never throws — it collects problems on each document,
83
105
  // so a single malformed file cannot abort the whole check.
84
- const docs = (0, yaml_1.parseAllDocuments)(source, { prettyErrors: true });
106
+ const lineCounter = new yaml_1.LineCounter();
107
+ const docs = (0, yaml_1.parseAllDocuments)(source, { prettyErrors: true, lineCounter });
85
108
  for (const doc of docs) {
109
+ for (const alias of unresolvedAliases(doc, lineCounter)) {
110
+ issues.push({
111
+ file,
112
+ blocking: true,
113
+ line: alias.line,
114
+ col: alias.col,
115
+ message: `Unresolved alias "*${alias.name}" (the anchor must be declared before the alias)`,
116
+ });
117
+ }
86
118
  const problems = [
87
119
  ...doc.errors.map(p => ({ problem: p, blocking: true })),
88
120
  ...doc.warnings.map(p => ({ problem: p, blocking: false })),
@@ -20,6 +20,13 @@ interface PackManifest {
20
20
  * package" and the check into a permanent skip.
21
21
  */
22
22
  export declare function pickManifest(parsed: unknown): PackManifest | null;
23
+ type PackResult = {
24
+ manifest: PackManifest;
25
+ } | {
26
+ failure: QueryFailure;
27
+ };
28
+ /** Describes a package as `npm pack` would; swapped out by tests that must not reach a registry. */
29
+ export type PackFn = (cwd: string, spec?: string, registry?: string) => Promise<PackResult>;
23
30
  /** Why we could not read a published version. */
24
31
  type QueryFailure = 'absent' | 'unauthenticated' | 'unreadable';
25
32
  /**
@@ -54,5 +61,6 @@ export declare function classifyQueryFailure(stderr: string): QueryFailure;
54
61
  * WASM binary landed — and a red build for it would be wrong. `severity:
55
62
  * { "Package Size": "error" }` makes it a gate for a repository that wants one.
56
63
  */
64
+ export declare function createPackageSizeCheck(pack?: PackFn): Check;
57
65
  export declare const packageSizeCheck: Check;
58
66
  export {};
@@ -8,6 +8,13 @@ import { logSample } from '../findings.js';
8
8
  const DEFAULT_MAX_GROWTH = 20;
9
9
  /** How many of the files behind the growth are named. */
10
10
  const NAMED_FILES = 5;
11
+ /**
12
+ * How long one registry request may take, and how long the whole `npm pack` may
13
+ * run. A registry that does not answer must end as "could not read", not as a
14
+ * check that holds the report for npm's default retry schedule.
15
+ */
16
+ const NPM_FETCH_TIMEOUT_MS = 10000;
17
+ const NPM_TIMEOUT_MS = 30000;
11
18
  /**
12
19
  * The manifest out of whatever `npm pack --json` printed.
13
20
  *
@@ -51,9 +58,11 @@ async function packManifest(cwd, spec, registry) {
51
58
  '--dry-run',
52
59
  '--json',
53
60
  '--ignore-scripts',
61
+ '--fetch-retries=0',
62
+ `--fetch-timeout=${NPM_FETCH_TIMEOUT_MS}`,
54
63
  ...(registry ? ['--registry', registry] : []),
55
64
  ];
56
- const result = await execa('npm', args, { cwd, reject: false });
65
+ const result = await execa('npm', args, { cwd, reject: false, timeout: NPM_TIMEOUT_MS });
57
66
  if (result.exitCode !== 0)
58
67
  return { failure: classifyQueryFailure(result.stderr ?? '') };
59
68
  try {
@@ -146,66 +155,69 @@ function published(cwd) {
146
155
  * WASM binary landed — and a red build for it would be wrong. `severity:
147
156
  * { "Package Size": "error" }` makes it a gate for a repository that wants one.
148
157
  */
149
- export const packageSizeCheck = {
150
- name: 'Package Size',
151
- phase: 'informational',
152
- weight: 'heavy',
153
- applies(ctx) {
154
- if (ctx.config.packageSize?.enabled === false)
155
- return false;
156
- // A private package is never published, so there is nothing to compare with
157
- // and no consumer to pay for the growth.
158
- return published(ctx.cwd) !== null;
159
- },
160
- async run(ctx) {
161
- const target = published(ctx.cwd);
162
- if (!target)
163
- return skip('no publishable package.json');
164
- const tag = ctx.config.packageSize?.compareWith ?? 'latest';
165
- const maxGrowth = ctx.config.packageSize?.maxGrowthPercent ?? DEFAULT_MAX_GROWTH;
166
- const spec = `${target.name}@${tag}`;
167
- const local = await packManifest(ctx.cwd);
168
- if (!('manifest' in local))
169
- return skip('npm could not describe this package');
170
- const current = local.manifest;
171
- // Never a pass: a green row over a comparison that did not happen is the
172
- // mistake of ticket 072. And never one reason standing in for three — the
173
- // three are different situations for the reader, and only one of them is a
174
- // fact about the package rather than about the run.
175
- const remote = await packManifest(ctx.cwd, spec, target.registry);
176
- if (!('manifest' in remote)) {
177
- // The fact, and nothing past it. The remedy for a private registry is three
178
- // lines in a workflow — `setup-node` with `registry-url`, a scope, and
179
- // `NODE_AUTH_TOKEN`, because npm reads the token through a reference in
180
- // `.npmrc` — and instructions that do not fit cannot be followed. They
181
- // belong on the check's page, which the report already links to.
182
- //
183
- // The package name is left out on purpose: the reader owns it. The host is
184
- // not, because it is the one part they cannot infer — and the difference
185
- // between "we asked the wrong registry" and "we were refused" is exactly
186
- // what this row got wrong before.
187
- // Named only when we know it. Without `publishConfig.registry` npm used
188
- // whatever the client's own config points at, and we do not know what that
189
- // was — "at the registry" would be filler dressed up as information.
190
- const host = target.registry ? new URL(target.registry).host : null;
191
- const at = host ? ` at ${host}` : '';
192
- const reason = {
193
- absent: `nothing published under this name${at} yet`,
194
- unauthenticated: `no credentials for ${host ?? 'the registry'}`,
195
- unreadable: `could not read the published version${at}`,
196
- }[remote.failure];
197
- return skip(reason);
198
- }
199
- const previous = remote.manifest;
200
- const delta = current.unpackedSize - previous.unpackedSize;
201
- const growth = previous.unpackedSize > 0 ? (delta / previous.unpackedSize) * 100 : 0;
202
- const summary = `${mb(previous.unpackedSize)} → ${mb(current.unpackedSize)} ` +
203
- `(${growth >= 0 ? '+' : ''}${growth.toFixed(1)}%) vs ${previous.version}`;
204
- if (growth <= maxGrowth) {
205
- logger.info(`[Package Size]: ✅ ${summary}`);
206
- return pass(summary);
207
- }
208
- logSample('Package Size', `${summary}:`, growthByFile(previous.files, current.files));
209
- return warn(summary);
210
- },
211
- };
158
+ export function createPackageSizeCheck(pack = packManifest) {
159
+ return {
160
+ name: 'Package Size',
161
+ phase: 'informational',
162
+ weight: 'heavy',
163
+ applies(ctx) {
164
+ if (ctx.config.packageSize?.enabled === false)
165
+ return false;
166
+ // A private package is never published, so there is nothing to compare with
167
+ // and no consumer to pay for the growth.
168
+ return published(ctx.cwd) !== null;
169
+ },
170
+ async run(ctx) {
171
+ const target = published(ctx.cwd);
172
+ if (!target)
173
+ return skip('no publishable package.json');
174
+ const tag = ctx.config.packageSize?.compareWith ?? 'latest';
175
+ const maxGrowth = ctx.config.packageSize?.maxGrowthPercent ?? DEFAULT_MAX_GROWTH;
176
+ const spec = `${target.name}@${tag}`;
177
+ const local = await pack(ctx.cwd);
178
+ if (!('manifest' in local))
179
+ return skip('npm could not describe this package');
180
+ const current = local.manifest;
181
+ // Never a pass: a green row over a comparison that did not happen is the
182
+ // mistake of ticket 072. And never one reason standing in for three — the
183
+ // three are different situations for the reader, and only one of them is a
184
+ // fact about the package rather than about the run.
185
+ const remote = await pack(ctx.cwd, spec, target.registry);
186
+ if (!('manifest' in remote)) {
187
+ // The fact, and nothing past it. The remedy for a private registry is three
188
+ // lines in a workflow — `setup-node` with `registry-url`, a scope, and
189
+ // `NODE_AUTH_TOKEN`, because npm reads the token through a reference in
190
+ // `.npmrc` — and instructions that do not fit cannot be followed. They
191
+ // belong on the check's page, which the report already links to.
192
+ //
193
+ // The package name is left out on purpose: the reader owns it. The host is
194
+ // not, because it is the one part they cannot infer — and the difference
195
+ // between "we asked the wrong registry" and "we were refused" is exactly
196
+ // what this row got wrong before.
197
+ // Named only when we know it. Without `publishConfig.registry` npm used
198
+ // whatever the client's own config points at, and we do not know what that
199
+ // was — "at the registry" would be filler dressed up as information.
200
+ const host = target.registry ? new URL(target.registry).host : null;
201
+ const at = host ? ` at ${host}` : '';
202
+ const reason = {
203
+ absent: `nothing published under this name${at} yet`,
204
+ unauthenticated: `no credentials for ${host ?? 'the registry'}`,
205
+ unreadable: `could not read the published version${at}`,
206
+ }[remote.failure];
207
+ return skip(reason);
208
+ }
209
+ const previous = remote.manifest;
210
+ const delta = current.unpackedSize - previous.unpackedSize;
211
+ const growth = previous.unpackedSize > 0 ? (delta / previous.unpackedSize) * 100 : 0;
212
+ const summary = `${mb(previous.unpackedSize)} → ${mb(current.unpackedSize)} ` +
213
+ `(${growth >= 0 ? '+' : ''}${growth.toFixed(1)}%) vs ${previous.version}`;
214
+ if (growth <= maxGrowth) {
215
+ logger.info(`[Package Size]: ✅ ${summary}`);
216
+ return pass(summary);
217
+ }
218
+ logSample('Package Size', `${summary}:`, growthByFile(previous.files, current.files));
219
+ return warn(summary);
220
+ },
221
+ };
222
+ }
223
+ export const packageSizeCheck = createPackageSizeCheck();
@@ -1,3 +1,4 @@
1
+ import { Document, LineCounter } from 'yaml';
1
2
  import { Check } from '../../types.js';
2
3
  /**
3
4
  * True when a parser problem describes something actually wrong with the file —
@@ -6,6 +7,20 @@ import { Check } from '../../types.js';
6
7
  export declare function isFileDefect(problem: {
7
8
  code?: string;
8
9
  }): boolean;
10
+ /**
11
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
12
+ *
13
+ * The parser does not treat this as a parse error: it builds the alias node and only
14
+ * throws when the document is converted to a JS value, which is exactly what a CI
15
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
16
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
17
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
18
+ */
19
+ export declare function unresolvedAliases(doc: Document.Parsed, lineCounter: LineCounter): {
20
+ name: string;
21
+ line: number;
22
+ col: number;
23
+ }[];
9
24
  /**
10
25
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
11
26
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { parseAllDocuments } from 'yaml';
3
+ import { LineCounter, parseAllDocuments, visit } from 'yaml';
4
4
  import { logger, matchesAnyGlob } from '../../../utils/index.js';
5
5
  import { resolveTargetFiles } from '../../targets.js';
6
6
  import { pass, skip } from '../../types.js';
@@ -31,6 +31,27 @@ const NON_DEFECT_CODES = new Set(['TAG_RESOLVE_FAILED']);
31
31
  export function isFileDefect(problem) {
32
32
  return problem.code === undefined || !NON_DEFECT_CODES.has(problem.code);
33
33
  }
34
+ /**
35
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
36
+ *
37
+ * The parser does not treat this as a parse error: it builds the alias node and only
38
+ * throws when the document is converted to a JS value, which is exactly what a CI
39
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
40
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
41
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
42
+ */
43
+ export function unresolvedAliases(doc, lineCounter) {
44
+ const found = [];
45
+ visit(doc, {
46
+ Alias(_key, node) {
47
+ if (node.resolve(doc) !== undefined)
48
+ return;
49
+ const { line, col } = lineCounter.linePos(node.range?.[0] ?? 0);
50
+ found.push({ name: node.source, line, col });
51
+ },
52
+ });
53
+ return found;
54
+ }
34
55
  /**
35
56
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
36
57
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -74,8 +95,18 @@ export const yamlLintCheck = {
74
95
  }
75
96
  // parseAllDocuments never throws — it collects problems on each document,
76
97
  // so a single malformed file cannot abort the whole check.
77
- const docs = parseAllDocuments(source, { prettyErrors: true });
98
+ const lineCounter = new LineCounter();
99
+ const docs = parseAllDocuments(source, { prettyErrors: true, lineCounter });
78
100
  for (const doc of docs) {
101
+ for (const alias of unresolvedAliases(doc, lineCounter)) {
102
+ issues.push({
103
+ file,
104
+ blocking: true,
105
+ line: alias.line,
106
+ col: alias.col,
107
+ message: `Unresolved alias "*${alias.name}" (the anchor must be declared before the alias)`,
108
+ });
109
+ }
79
110
  const problems = [
80
111
  ...doc.errors.map(p => ({ problem: p, blocking: true })),
81
112
  ...doc.warnings.map(p => ({ problem: p, blocking: false })),
@@ -11,7 +11,7 @@
11
11
  "execa": "9.6.1",
12
12
  "yaml": "2.9.1",
13
13
  "inquirer": "14.2.2",
14
- "gitleaks-secret-scanner-fixed": "3.0.4"
14
+ "gitleaks-secret-scanner-fixed": "3.0.6"
15
15
  }
16
16
  },
17
17
  "node_modules/@inquirer/ansi": {
@@ -573,15 +573,15 @@
573
573
  }
574
574
  },
575
575
  "node_modules/gitleaks-secret-scanner-fixed": {
576
- "version": "3.0.4",
577
- "resolved": "https://registry.npmjs.org/gitleaks-secret-scanner-fixed/-/gitleaks-secret-scanner-fixed-3.0.4.tgz",
578
- "integrity": "sha512-UxNQSWIcSIux7M6Ue/pcEDz+E/UQVicvpoa/a5TCq6PD32B3sSNe3WY7wIpiyddpJIUImF2gFkQbWM+wZQrKFQ==",
576
+ "version": "3.0.6",
577
+ "resolved": "https://registry.npmjs.org/gitleaks-secret-scanner-fixed/-/gitleaks-secret-scanner-fixed-3.0.6.tgz",
578
+ "integrity": "sha512-qW7JSjmuuPGZxn3b6QUu2etB7i5b1xl6Mx6KFodqzw+C4NYifP4YKW0dtKuwc1YYJAI/h3fEM0EftSwFjxy57A==",
579
579
  "hasInstallScript": true,
580
580
  "license": "MIT",
581
581
  "dependencies": {
582
582
  "fs-extra": "^11.2.0",
583
583
  "tar": "^7.5.22",
584
- "toml": "^5.0.0",
584
+ "toml": "^5.0.1",
585
585
  "unzipper": "^0.12.3"
586
586
  },
587
587
  "bin": {
@@ -5,7 +5,7 @@
5
5
  "execa": "9.6.1",
6
6
  "yaml": "2.9.1",
7
7
  "inquirer": "14.2.2",
8
- "gitleaks-secret-scanner-fixed": "3.0.4"
8
+ "gitleaks-secret-scanner-fixed": "3.0.6"
9
9
  },
10
10
  "private": true
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "4.0.1",
3
+ "version": "4.1.1",
4
4
  "description": "Security-first PR gate for CI: secret scanning, grype dependency scanning, SBOM — plus linting and formatting across 11 languages",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -57,7 +57,7 @@
57
57
  "lint": "eslint \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" --fix",
58
58
  "prettier": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\" \"tests/**/*.{ts,tsx}\"",
59
59
  "spellcheck": "cspell '**/*.{ts,tsx,js,jsx,md,json}' --no-progress",
60
- "docs": "typedoc",
60
+ "docs": "patch-package && typedoc",
61
61
  "scan:secrets": "gitleaks-secret-scanner --diff-mode ci --html-report gitleaks-report.html",
62
62
  "test": "node --require ./scripts/ts-node-nodenext-resolve.cjs --require ts-node/register --test --test-timeout=60000 \"tests/**/*.test.ts\"",
63
63
  "test:ci": "bash scripts/test-per-file.sh",
@@ -83,7 +83,7 @@
83
83
  "license": "LicenseRef-PR-Checkmate-Proprietary",
84
84
  "dependencies": {
85
85
  "execa": "^9.6.0",
86
- "gitleaks-secret-scanner-fixed": "3.0.4",
86
+ "gitleaks-secret-scanner-fixed": "3.0.6",
87
87
  "inquirer": "^14.1.0",
88
88
  "yaml": "^2.9.0"
89
89
  },
@@ -110,6 +110,7 @@
110
110
  "knip": "^6.32.2",
111
111
  "license-checker-rseidelsohn": "^5.0.1",
112
112
  "markdownlint": "^0.41.1",
113
+ "patch-package": "^8.0.1",
113
114
  "prettier": "^3.9.6",
114
115
  "pyright": "^1.1.413",
115
116
  "standard-version": "^9.5.0",