@pareto4/pp-core 1.9.0 → 1.11.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/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-fixer.md +11 -0
- package/agents/pp-code-reviewer.md +77 -1
- package/agents/pp-executor.md +34 -1
- package/agents/pp-planner.md +24 -103
- package/commands/pp-ns-manage.md +2 -1
- package/commands/pp-vault-promote.md +31 -0
- package/gemini-extension.json +1 -1
- package/package.json +5 -3
- package/pareto-skills/pp-brand/SKILL.md +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +160 -3
- package/pp-core/bin/lib/capability-state.cjs +41 -3
- package/pp-core/bin/lib/check-command-router.cjs +473 -3
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/compose-bindings.cjs +736 -0
- package/pp-core/bin/lib/git-provider.cjs +279 -0
- package/pp-core/bin/lib/init-contract.cjs +989 -0
- package/pp-core/bin/lib/init-interactive.cjs +183 -0
- package/pp-core/bin/lib/io.cjs +9 -0
- package/pp-core/bin/lib/org-profile.cjs +696 -0
- package/pp-core/bin/lib/platform-bindings.cjs +268 -64
- package/pp-core/bin/lib/practices-review-check.cjs +938 -0
- package/pp-core/bin/lib/vault-command-router.cjs +239 -2
- package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
- package/pp-core/bin/lib/vault-practices.cjs +982 -0
- package/pp-core/bin/lib/vault-write-back.cjs +842 -0
- package/pp-core/bin/lib/vault.cjs +170 -51
- package/pp-core/bin/pp-tools.cjs +225 -2
- package/pp-core/references/planner-checkpoints.md +72 -0
- package/pp-core/references/planner-tdd.md +55 -0
- package/pp-core/references/planner-vault-practices.md +67 -0
- package/pp-core/templates/practices/README.md +122 -0
- package/pp-core/templates/practices/error-handling.md +18 -0
- package/pp-core/templates/practices/minimal-comments.md +14 -0
- package/pp-core/templates/practices/naming-convention.md +18 -0
- package/pp-core/templates/practices/practice-template.md +23 -0
- package/pp-core/workflows/code-review.md +74 -18
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
- package/pp-core/workflows/init.md +137 -76
- package/pp-core/workflows/ship.md +1 -0
- package/pp-core/workflows/vault-write-back.md +213 -0
- package/scripts/gen-capability-registry.cjs +68 -8
- package/scripts/gen-platform-bindings-schema.cjs +148 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
- package/scripts/lint-descriptions.cjs +3 -1
- package/scripts/lint-legacy-dir-name.cjs +5 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
|
@@ -23,14 +23,18 @@
|
|
|
23
23
|
* per D-03; provisional namespaces are validated leniently — an unknown or
|
|
24
24
|
* missing namespace never makes the object invalid.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
* `schemas/platform-bindings.schema.json
|
|
28
|
-
*
|
|
26
|
+
* `platform.repo.pattern` and `client.vaultRepo.pattern` in
|
|
27
|
+
* `schemas/platform-bindings.schema.json` are GENERATED from the two exported
|
|
28
|
+
* pattern-source constants below by `scripts/gen-platform-bindings-schema.cjs`
|
|
29
|
+
* (32-02, D-06) — a hand edit to either pattern in the schema is caught by
|
|
30
|
+
* `tests/gen-platform-bindings-schema.test.cjs`; the schema↔validator coupling
|
|
31
|
+
* test in `tests/platform-bindings.test.cjs` remains as the second net.
|
|
29
32
|
*/
|
|
30
33
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
31
34
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
32
35
|
};
|
|
33
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.MACHINE_SPECIFIC_CLONE_TARGET_PATTERNS = exports.VAULT_REPO_PATTERN_SOURCE = exports.PLATFORM_REPO_PATTERN_SOURCE = exports.CONTRACT_SCHEMA_VERSION = void 0;
|
|
34
38
|
exports.validateBindings = validateBindings;
|
|
35
39
|
exports.cmdPlatformBindings = cmdPlatformBindings;
|
|
36
40
|
exports.cmdValidateBindings = cmdValidateBindings;
|
|
@@ -41,19 +45,99 @@ const io = require("./io.cjs");
|
|
|
41
45
|
const { output, error, ERROR_REASON } = io;
|
|
42
46
|
const project_root_cjs_1 = require("./project-root.cjs");
|
|
43
47
|
const semver_compare_cjs_1 = require("./semver-compare.cjs");
|
|
44
|
-
|
|
45
|
-
//
|
|
48
|
+
const git_provider_cjs_1 = require("./git-provider.cjs");
|
|
49
|
+
// Core load-bearing patterns (D-01). SCHEMA_VERSION_RE, PLATFORM_TAG_RE and
|
|
50
|
+
// SLUG_RE are unchanged by 32-02 and stay hand-authored, mirroring the
|
|
51
|
+
// schema's `pattern` fields for those three by convention (no generator owns
|
|
52
|
+
// them). PLATFORM_REPO_RE and VAULT_REPO_RE are compiled below from the two
|
|
53
|
+
// exported pattern-source constants, which ARE what the generator owns.
|
|
46
54
|
const SCHEMA_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
47
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The contract version a **newly composed** binding emits (34-02). This is
|
|
57
|
+
* deliberately NOT a validation constraint: Phase 32 D-08 keeps every
|
|
58
|
+
* `1.0.0` file valid indefinitely, so `SCHEMA_VERSION_RE` above stays a shape
|
|
59
|
+
* check (any semver triplet) rather than an equality check against this
|
|
60
|
+
* constant. `scripts/gen-platform-bindings-schema.cjs` carries this value
|
|
61
|
+
* into the committed schema's `properties.schemaVersion.default` (an
|
|
62
|
+
* annotation-only JSON Schema keyword) so the two can never drift.
|
|
63
|
+
*/
|
|
64
|
+
exports.CONTRACT_SCHEMA_VERSION = '1.1.0';
|
|
48
65
|
const PLATFORM_TAG_RE = /^v\d+\.\d+\.\d+/;
|
|
49
66
|
// Provisional namespace pattern (lenient): only enforced if client.slug is present.
|
|
50
67
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
68
|
+
// A host-relative path of two or more slash-separated segments built from
|
|
69
|
+
// word/dot/hyphen characters — e.g. `pareto4/pareto-platform-gcp` or Azure's
|
|
70
|
+
// four-segment `org/project/_git/repo`. This is the pre-v1.1.0 vault-repo
|
|
71
|
+
// pattern's shape; it now serves two fields (D-05, D-06). Linear complexity:
|
|
72
|
+
// anchored at both ends, a single non-capturing repeated group over a
|
|
73
|
+
// disjoint character class — no nested or ambiguous quantifier (T-11-01).
|
|
74
|
+
const HOST_RELATIVE_SLUG_SOURCE = '^[\\w.-]+(?:\\/[\\w.-]+)+$';
|
|
75
|
+
/**
|
|
76
|
+
* A host-relative path of two or more segments. Compiled into
|
|
77
|
+
* `platform.repo.pattern` by `scripts/gen-platform-bindings-schema.cjs` so
|
|
78
|
+
* the runtime regex and the schema pattern cannot drift (D-06). Deliberately
|
|
79
|
+
* accepts no URL and no SSH form: `resolveLatestTagViaGitRemote` composes the
|
|
80
|
+
* host from `platform.host` itself (Phase 31 D-09), so a full URL here would
|
|
81
|
+
* give the host two competing encodings — `platform.host` is authoritative.
|
|
82
|
+
*/
|
|
83
|
+
exports.PLATFORM_REPO_PATTERN_SOURCE = HOST_RELATIVE_SLUG_SOURCE;
|
|
84
|
+
// The literal-forms alternative: LITERAL_CLONE_TARGET_PATTERNS entries with
|
|
85
|
+
// their leading caret stripped, joined by the alternation bar, guarded by a
|
|
86
|
+
// single leading negative lookahead refusing a leading hyphen (T-32-02,
|
|
87
|
+
// SC4/T-29-26). Built programmatically from the imported array — never by
|
|
88
|
+
// pasting the seven fragments here, which would be invisible to the BIND-02
|
|
89
|
+
// regression lock (32-01) and would reintroduce the WR-01 defect this plan
|
|
90
|
+
// closes.
|
|
91
|
+
const literalFormsAlternative = '^(?!-)(?:' + git_provider_cjs_1.LITERAL_CLONE_TARGET_PATTERNS.map((source) => source.slice(1)).join('|') + ')';
|
|
92
|
+
/**
|
|
93
|
+
* The union of the host-relative slug shape (unchanged) with the seven
|
|
94
|
+
* literal clone-target forms from `LITERAL_CLONE_TARGET_PATTERNS`. Compiled
|
|
95
|
+
* into `client.vaultRepo.pattern` by `scripts/gen-platform-bindings-schema.cjs`
|
|
96
|
+
* (D-06).
|
|
97
|
+
*
|
|
98
|
+
* The leading-hyphen negative lookahead guards ONLY the literal-forms
|
|
99
|
+
* alternative, appearing exactly once. It is deliberately NOT added to the
|
|
100
|
+
* slug alternative: a dash-leading slug (`-x/y`) matches today, is not a
|
|
101
|
+
* literal clone target, and is therefore always routed through URL
|
|
102
|
+
* composition rather than handed to git verbatim — a second guard here would
|
|
103
|
+
* reject a value the runtime already handles safely. Do not "fix" this later.
|
|
104
|
+
*
|
|
105
|
+
* ReDoS (T-11-01, restated for the union): both alternatives use only
|
|
106
|
+
* non-nested quantifiers over disjoint character classes, and the
|
|
107
|
+
* alternation adds no ambiguity — a literal-form prefix and a slug prefix
|
|
108
|
+
* cannot both consume an unbounded prefix of the same input.
|
|
109
|
+
*/
|
|
110
|
+
exports.VAULT_REPO_PATTERN_SOURCE = '(?:' + HOST_RELATIVE_SLUG_SOURCE + ')|(?:' + literalFormsAlternative + ')';
|
|
111
|
+
const PLATFORM_REPO_RE = new RegExp(exports.PLATFORM_REPO_PATTERN_SOURCE);
|
|
112
|
+
const VAULT_REPO_RE = new RegExp(exports.VAULT_REPO_PATTERN_SOURCE);
|
|
113
|
+
// The four MACHINE-SPECIFIC forms among LITERAL_CLONE_TARGET_PATTERNS: explicit
|
|
114
|
+
// relative (`./`), parent-relative (`../`), home-relative (`~`), and Windows
|
|
115
|
+
// drive (`C:\`). These are valid clone targets (D-03) but resolve differently
|
|
116
|
+
// on each developer's machine, unlike an absolute unix path, a scheme URL, or
|
|
117
|
+
// an scp-style SSH target — all of which are shareable coordinates. This is
|
|
118
|
+
// D-04's line between validity and advisability; D-07 records that the schema
|
|
119
|
+
// cannot express it, so the advisory lives only here.
|
|
120
|
+
//
|
|
121
|
+
// Derived by FILTERING LITERAL_CLONE_TARGET_PATTERNS, not by retyping the four
|
|
122
|
+
// fragments: a future edit to any of those four entries in
|
|
123
|
+
// `src/git-provider.cts` drops the length below four and fails the subset-pin
|
|
124
|
+
// test in tests/platform-bindings.test.cjs, whereas a retyped copy would
|
|
125
|
+
// silently diverge.
|
|
126
|
+
const MACHINE_SPECIFIC_SOURCE_SET = new Set([
|
|
127
|
+
'^\\.\\/', // explicit relative — ./path
|
|
128
|
+
'^\\.\\.\\/', // parent-relative — ../path
|
|
129
|
+
'^~', // home-relative — ~ or ~/path
|
|
130
|
+
'^[A-Za-z]:[\\\\/]', // Windows drive path — C:\ or C:/
|
|
131
|
+
]);
|
|
132
|
+
exports.MACHINE_SPECIFIC_CLONE_TARGET_PATTERNS = Object.freeze(git_provider_cjs_1.LITERAL_CLONE_TARGET_PATTERNS.filter((source) => MACHINE_SPECIFIC_SOURCE_SET.has(source)));
|
|
133
|
+
// Compiled once at module load, as the predicate above does for the full set.
|
|
134
|
+
const compiledMachineSpecificPatterns = exports.MACHINE_SPECIFIC_CLONE_TARGET_PATTERNS.map((source) => new RegExp(source));
|
|
135
|
+
// Fixed, single-line advisory text (T-32-05): neither warning interpolates any
|
|
136
|
+
// binding-supplied value, so no bounding/truncation helper (sanitizeForWarning
|
|
137
|
+
// in src/git-provider.cts) is needed a third time in this file. Naming the
|
|
138
|
+
// field and the cause is enough for an operator to act.
|
|
139
|
+
const TAG_ABSENT_WARNING = 'platform.tag is absent: the platform is unpinned and drift checking is degraded.';
|
|
140
|
+
const MACHINE_SPECIFIC_VAULT_WARNING = 'client.vaultRepo is machine-specific (dot-, parent-, home-relative, or drive-letter) — will not resolve elsewhere.';
|
|
57
141
|
function isObject(v) {
|
|
58
142
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
59
143
|
}
|
|
@@ -66,24 +150,33 @@ function isObject(v) {
|
|
|
66
150
|
*/
|
|
67
151
|
function validateBindings(o) {
|
|
68
152
|
const errors = [];
|
|
153
|
+
const warnings = [];
|
|
69
154
|
if (!isObject(o)) {
|
|
70
|
-
return { valid: false, errors: ['root: expected a JSON object'] };
|
|
155
|
+
return { valid: false, errors: ['root: expected a JSON object'], warnings: [] };
|
|
71
156
|
}
|
|
72
157
|
// schemaVersion — required, semver triplet.
|
|
73
158
|
if (typeof o.schemaVersion !== 'string' || !SCHEMA_VERSION_RE.test(o.schemaVersion)) {
|
|
74
159
|
errors.push('schemaVersion: missing or not a semver triplet (e.g. "1.0.0")');
|
|
75
160
|
}
|
|
76
|
-
// platform — required object with repo
|
|
161
|
+
// platform — required object with repo; tag is present-only (D-01/D-02).
|
|
77
162
|
if (!isObject(o.platform)) {
|
|
78
163
|
errors.push('platform: missing or not an object');
|
|
79
164
|
}
|
|
80
165
|
else {
|
|
81
166
|
const platform = o.platform;
|
|
82
167
|
if (typeof platform.repo !== 'string' || !PLATFORM_REPO_RE.test(platform.repo)) {
|
|
83
|
-
errors.push('platform.repo: missing or not a
|
|
168
|
+
errors.push('platform.repo: missing or not a host-relative path of two or more segments');
|
|
84
169
|
}
|
|
85
|
-
|
|
86
|
-
|
|
170
|
+
// D-01: absence is valid but advisory (D-02) — a tagless client platform
|
|
171
|
+
// repo can bind. Absence means the key is `undefined`; a present empty
|
|
172
|
+
// string, a present non-string, or a present malformed string all stay
|
|
173
|
+
// errors, mirroring the present-only idiom already used below for
|
|
174
|
+
// client.slug, client.vaultRepo and project.slug.
|
|
175
|
+
if (platform.tag === undefined) {
|
|
176
|
+
warnings.push(TAG_ABSENT_WARNING);
|
|
177
|
+
}
|
|
178
|
+
else if (typeof platform.tag !== 'string' || !PLATFORM_TAG_RE.test(platform.tag)) {
|
|
179
|
+
errors.push('platform.tag: present but not a "v<semver>" tag');
|
|
87
180
|
}
|
|
88
181
|
}
|
|
89
182
|
// Provisional namespaces — lenient. A missing/unknown namespace is fine; only
|
|
@@ -96,8 +189,14 @@ function validateBindings(o) {
|
|
|
96
189
|
// client.vaultRepo — lenient present-only check, mirrors client.slug pattern.
|
|
97
190
|
// Only fires when vaultRepo is actually present; absent vault fields are fully valid (D-03).
|
|
98
191
|
if (isObject(o.client) && o.client.vaultRepo !== undefined) {
|
|
99
|
-
|
|
100
|
-
|
|
192
|
+
const vaultRepo = o.client.vaultRepo;
|
|
193
|
+
if (typeof vaultRepo !== 'string' || !VAULT_REPO_RE.test(vaultRepo)) {
|
|
194
|
+
errors.push('client.vaultRepo: present but not a host-relative path or a literal clone target (URL, scp-style SSH, absolute, relative, home or Windows drive path)');
|
|
195
|
+
}
|
|
196
|
+
else if (compiledMachineSpecificPatterns.some((re) => re.test(vaultRepo))) {
|
|
197
|
+
// D-04: only reachable once the pattern check above passed — a rejected
|
|
198
|
+
// value already has an error and does not need a second line about it.
|
|
199
|
+
warnings.push(MACHINE_SPECIFIC_VAULT_WARNING);
|
|
101
200
|
}
|
|
102
201
|
}
|
|
103
202
|
// IN-03: project.slug — symmetric lenient check matching client.slug behaviour.
|
|
@@ -107,7 +206,7 @@ function validateBindings(o) {
|
|
|
107
206
|
errors.push('project.slug: present but not a url-safe lowercase slug');
|
|
108
207
|
}
|
|
109
208
|
}
|
|
110
|
-
return { valid: errors.length === 0, errors };
|
|
209
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
111
210
|
}
|
|
112
211
|
/**
|
|
113
212
|
* Reader handler for `pp-tools query platform-bindings`.
|
|
@@ -161,24 +260,40 @@ function cmdPlatformBindings(cwd, args, raw) {
|
|
|
161
260
|
// no YAML dependency, no shell-eval).
|
|
162
261
|
const GITLAB_CI_MODULE_REF_RE = /\?ref=([^\s'"&)\]]+)/g;
|
|
163
262
|
/**
|
|
164
|
-
* Extracts
|
|
263
|
+
* Extracts pin values that belong to the top-level `include:` block in a
|
|
165
264
|
* .gitlab-ci.yml file, using a stateful line-scan.
|
|
166
265
|
*
|
|
167
266
|
* APPROXIMATION: Parses YAML structure via indentation tracking — handles the
|
|
168
|
-
* common block-sequence form (`include: [- project: ... ref: ...]`)
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
267
|
+
* common block-sequence form (`include: [- project: ... ref: ...]`), the
|
|
268
|
+
* list-item-first form where `ref:` is the first key of the item (`- ref: ...`,
|
|
269
|
+
* CR-01), the single-line flow-style form (`include: [{project: p/q, ref:
|
|
270
|
+
* v3.0.0}]`, CR-01), and CI/CD component pins (`- component: <path>@<version>`,
|
|
271
|
+
* CR-01 — the currently GitLab-recommended pin form). It does NOT capture
|
|
272
|
+
* `ref:`/`component:` values from `trigger:`, `needs:`, or other non-include
|
|
273
|
+
* stanzas, because those appear under job-level keys (indent = 0) which close
|
|
274
|
+
* the include context. May still mis-classify more exotic YAML (anchors,
|
|
275
|
+
* multi-document files, multi-line flow spanning several lines). Uses no YAML
|
|
276
|
+
* parser to avoid runtime dependencies (T-07-04).
|
|
173
277
|
*
|
|
174
278
|
* @param content - Raw string content of the .gitlab-ci.yml file.
|
|
175
|
-
* @returns Array of
|
|
279
|
+
* @returns Array of pin values (tag/branch refs and component `@version`
|
|
280
|
+
* pins) found inside the `include:` block.
|
|
176
281
|
*/
|
|
177
282
|
function extractGitlabCiIncludeRefs(content) {
|
|
178
283
|
const refs = [];
|
|
179
284
|
const lines = content.split('\n');
|
|
180
285
|
let inIncludeBlock = false;
|
|
181
|
-
|
|
286
|
+
// CR-01: optional leading list-item marker (`- `) so `ref:` as the first
|
|
287
|
+
// key of a sequence item is still anchored-matched (YAML mapping key order
|
|
288
|
+
// is arbitrary; `- ref: ...` / `- project: ...` are both ordinary).
|
|
289
|
+
const REF_LINE_RE = /^(?:-\s+)?ref:\s*['"]?([^\s'"#,\]}]+)['"]?/;
|
|
290
|
+
// CR-01: CI/CD component pin — `- component: <path>@<version>`.
|
|
291
|
+
const COMPONENT_LINE_RE = /^(?:-\s+)?component:\s*['"]?\S+@([^\s'"#,\]}]+)['"]?/;
|
|
292
|
+
// CR-01: same `ref:` shape, applied to a flow-style inline sequence/mapping
|
|
293
|
+
// (e.g. the remainder of `include: [{project: p/q, ref: v3.0.0}]`), where
|
|
294
|
+
// multiple entries may share one line and are not indent-delimited.
|
|
295
|
+
const INLINE_REF_RE = /ref:\s*['"]?([^\s'"#,\]}]+)['"]?/g;
|
|
296
|
+
const INLINE_COMPONENT_RE = /component:\s*['"]?\S+?@([^\s'"#,\]}]+)['"]?/g;
|
|
182
297
|
for (const line of lines) {
|
|
183
298
|
const trimmed = line.trimStart();
|
|
184
299
|
// Skip blank lines and comment lines — they do not change YAML context.
|
|
@@ -188,18 +303,81 @@ function extractGitlabCiIncludeRefs(content) {
|
|
|
188
303
|
// A new top-level key (indent = 0) either enters or exits the include block.
|
|
189
304
|
if (indent === 0) {
|
|
190
305
|
inIncludeBlock = trimmed === 'include:' || trimmed.startsWith('include:');
|
|
306
|
+
// CR-01: a flow-style include can carry its entries on the SAME line as
|
|
307
|
+
// the `include:` key (e.g. `include: [{project: p/q, ref: v3.0.0}]`).
|
|
308
|
+
// Scan the remainder of this line for inline ref/component pins before
|
|
309
|
+
// moving on — the block below only sees subsequent, indented lines.
|
|
310
|
+
if (inIncludeBlock) {
|
|
311
|
+
const remainder = trimmed.slice('include:'.length);
|
|
312
|
+
let m;
|
|
313
|
+
const inlineRefRe = new RegExp(INLINE_REF_RE.source, INLINE_REF_RE.flags);
|
|
314
|
+
while ((m = inlineRefRe.exec(remainder)) !== null) {
|
|
315
|
+
refs.push(m[1].trim());
|
|
316
|
+
}
|
|
317
|
+
const inlineComponentRe = new RegExp(INLINE_COMPONENT_RE.source, INLINE_COMPONENT_RE.flags);
|
|
318
|
+
while ((m = inlineComponentRe.exec(remainder)) !== null) {
|
|
319
|
+
refs.push(m[1].trim());
|
|
320
|
+
}
|
|
321
|
+
}
|
|
191
322
|
continue;
|
|
192
323
|
}
|
|
193
|
-
// Within the include block, capture any `ref
|
|
324
|
+
// Within the include block, capture any `ref:` or `component:` pin line.
|
|
194
325
|
if (inIncludeBlock) {
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
refs.push(
|
|
326
|
+
const refMatch = trimmed.match(REF_LINE_RE);
|
|
327
|
+
if (refMatch) {
|
|
328
|
+
refs.push(refMatch[1].trim());
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const componentMatch = trimmed.match(COMPONENT_LINE_RE);
|
|
332
|
+
if (componentMatch) {
|
|
333
|
+
refs.push(componentMatch[1].trim());
|
|
198
334
|
}
|
|
199
335
|
}
|
|
200
336
|
}
|
|
201
337
|
return refs;
|
|
202
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Collects pin-site tags from the REAL on-disk `.gitlab-ci.yml` at `root`
|
|
341
|
+
* (D-06): the top-level `include:` block's `ref:` values and any module
|
|
342
|
+
* `?ref=` values in source lines. Returns an empty record when the file does
|
|
343
|
+
* not exist. Moved verbatim out of `cmdValidateBindings` (32-03, D-12) so the
|
|
344
|
+
* D-12 tagless/competing-pin-site split can collect this evidence BEFORE
|
|
345
|
+
* deciding on a missing `platform.tag` — the two loops, the site key naming
|
|
346
|
+
* (first ref unnumbered, subsequent ones bracket-numbered), and the
|
|
347
|
+
* freshly-constructed module-ref regex are byte-equivalent to the pre-32-03
|
|
348
|
+
* inline block.
|
|
349
|
+
*
|
|
350
|
+
* @param root - Project root; the file checked is `<root>/.gitlab-ci.yml`.
|
|
351
|
+
* @returns A `PinSites` record, empty when no `.gitlab-ci.yml` exists.
|
|
352
|
+
*/
|
|
353
|
+
function collectGitlabCiPinSites(root) {
|
|
354
|
+
const sites = {};
|
|
355
|
+
const gitlabCiPath = node_path_1.default.join(root, '.gitlab-ci.yml');
|
|
356
|
+
if (node_fs_1.default.existsSync(gitlabCiPath)) {
|
|
357
|
+
const ciContent = node_fs_1.default.readFileSync(gitlabCiPath, 'utf-8');
|
|
358
|
+
// Extract `ref:` values from include blocks using a stateful line-scan (WR-02).
|
|
359
|
+
// Avoids false positives from `trigger:`, `needs:`, and other non-include stanzas.
|
|
360
|
+
const includeRefs = extractGitlabCiIncludeRefs(ciContent);
|
|
361
|
+
for (let refIdx = 0; refIdx < includeRefs.length; refIdx++) {
|
|
362
|
+
const key = refIdx === 0
|
|
363
|
+
? '.gitlab-ci.yml include ref'
|
|
364
|
+
: `.gitlab-ci.yml include ref[${refIdx + 1}]`;
|
|
365
|
+
sites[key] = includeRefs[refIdx];
|
|
366
|
+
}
|
|
367
|
+
// Extract `?ref=` values from module source lines.
|
|
368
|
+
const moduleRefRe = new RegExp(GITLAB_CI_MODULE_REF_RE.source, GITLAB_CI_MODULE_REF_RE.flags);
|
|
369
|
+
let m;
|
|
370
|
+
let modIdx = 0;
|
|
371
|
+
while ((m = moduleRefRe.exec(ciContent)) !== null) {
|
|
372
|
+
modIdx++;
|
|
373
|
+
const key = modIdx === 1
|
|
374
|
+
? '.gitlab-ci.yml module ?ref'
|
|
375
|
+
: `.gitlab-ci.yml module ?ref[${modIdx}]`;
|
|
376
|
+
sites[key] = m[1].trim();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return sites;
|
|
380
|
+
}
|
|
203
381
|
/**
|
|
204
382
|
* Pin-site consistency check for `pp-tools query validate-bindings` (BIND-03).
|
|
205
383
|
*
|
|
@@ -207,16 +385,34 @@ function extractGitlabCiIncludeRefs(content) {
|
|
|
207
385
|
* cross-checks them against `platform.tag` in `.platform-bindings.json`.
|
|
208
386
|
*
|
|
209
387
|
* Behaviour matrix (locked by tests/platform-bindings.test.cjs):
|
|
210
|
-
* absent .platform-bindings.json
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
388
|
+
* absent .platform-bindings.json → output({present:false}) exit 0 (inert, D-07)
|
|
389
|
+
* tag key absent, no competing pin-site → output({..., status:'warn'}) exit 0 (D-12, tagless)
|
|
390
|
+
* tag key absent, a pin-site carries a tag → output({..., status:'block'}) exit 0 (D-12, drift)
|
|
391
|
+
* tag present but empty/non-string/missing platform → output({..., status:'block'}) exit 0 (WR-01, unchanged)
|
|
392
|
+
* internal mismatch (>1 distinct) → output({..., status:'block'}) exit 0 (D-04)
|
|
393
|
+
* all-agree, behind --latest-tag → output({..., status:'warn'}) exit 0 (D-05)
|
|
394
|
+
* all-agree, at/ahead of latest → output({..., status:'pass'}) exit 0
|
|
395
|
+
*
|
|
396
|
+
* D-12: a tagless platform (D-01/D-02) must not be confused with a genuine
|
|
397
|
+
* drift signal. `platform.tag` being absent (the KEY is `undefined`) is
|
|
398
|
+
* distinguished from it being present-but-empty/malformed: the former, when
|
|
399
|
+
* no `.gitlab-ci.yml` pin-site carries a tag either, is the tagless-client
|
|
400
|
+
* case D-01/D-02 exist to support and degrades to a `warn`; the former, when
|
|
401
|
+
* a pin-site DOES carry a tag, is loud drift and stays a `block` — "without
|
|
402
|
+
* weakening the guards" applies to this case directly. The latter
|
|
403
|
+
* (present-but-empty/non-string, or no `platform` object at all) is
|
|
404
|
+
* malformed, not tagless by design, and keeps the pre-32-03 WR-01 block
|
|
405
|
+
* unconditionally, whether or not a `.gitlab-ci.yml` exists.
|
|
214
406
|
*
|
|
215
407
|
* Threat mitigations:
|
|
216
408
|
* T-07-04 Anchored regex on `ref:`/`?ref=`; file content is untrusted text — never shell-eval.
|
|
217
409
|
* T-07-05 Unresolved/offline remote is a soft note, never a hard block; caller passes
|
|
218
410
|
* `--latest-tag <tag>` when a network lookup succeeded; absent = skip drift check.
|
|
219
411
|
* T-07-06 `compareSemverCore === 0` normalises v-prefix differences; literal diff surfaced in `sites`.
|
|
412
|
+
* T-32-07 D-12's split: an absent tag alongside any CI-carried tag stays a hard block, so
|
|
413
|
+
* relaxing the tagless case can never silence a genuine drift signal.
|
|
414
|
+
* T-32-08 A malformed (present-but-empty/non-string) tag does not inherit the tagless
|
|
415
|
+
* relaxation — the WR-01 guard is preserved byte-for-byte for that case.
|
|
220
416
|
*
|
|
221
417
|
* @param cwd - Working directory; resolved to repo root via findProjectRoot.
|
|
222
418
|
* @param args - Handler-local args (already sliced past the subcommand name).
|
|
@@ -256,9 +452,38 @@ function cmdValidateBindings(cwd, args, raw) {
|
|
|
256
452
|
const bindingsObj = parsed;
|
|
257
453
|
const platform = isObject(bindingsObj.platform) ? bindingsObj.platform : null;
|
|
258
454
|
const bindingsTag = typeof platform?.tag === 'string' ? platform.tag.trim() : '';
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
455
|
+
// D-12, case 1: the `platform` object is present and its `tag` KEY is
|
|
456
|
+
// absent (not present-but-empty/malformed — that stays WR-01 below).
|
|
457
|
+
// Collect the CI pin-sites BEFORE deciding: a tagless project degrades to a
|
|
458
|
+
// warn only when no other pin-site carries a tag either; a pin-site that
|
|
459
|
+
// DOES carry one is drift and must stay a block (T-32-07).
|
|
460
|
+
if (platform !== null && platform.tag === undefined) {
|
|
461
|
+
const ciSites = collectGitlabCiPinSites(root);
|
|
462
|
+
if (Object.keys(ciSites).length > 0) {
|
|
463
|
+
output({
|
|
464
|
+
present: true,
|
|
465
|
+
consistent: false,
|
|
466
|
+
status: 'block',
|
|
467
|
+
message: 'platform.tag is absent from .platform-bindings.json while other pin-sites carry a tag — run --validate to diagnose',
|
|
468
|
+
sites: ciSites,
|
|
469
|
+
}, raw);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
output({
|
|
473
|
+
present: true,
|
|
474
|
+
consistent: true,
|
|
475
|
+
status: 'warn',
|
|
476
|
+
message: 'platform.tag is absent — the platform is unpinned and pin-site drift checking is degraded',
|
|
477
|
+
sites: {},
|
|
478
|
+
}, raw);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// WR-01: guard on a missing/empty canonical tag — a present-but-empty or
|
|
482
|
+
// non-string tag, or no `platform` object at all, is malformed rather than
|
|
483
|
+
// tagless by design (T-32-08) and stays a block unconditionally. Without
|
|
484
|
+
// this guard the function would fall through to emit status:'pass' with
|
|
485
|
+
// zero pin-sites, misleadingly signalling a correct pin on a binding that
|
|
486
|
+
// validateBindings would reject.
|
|
262
487
|
const sites = {};
|
|
263
488
|
if (!bindingsTag) {
|
|
264
489
|
output({
|
|
@@ -271,31 +496,10 @@ function cmdValidateBindings(cwd, args, raw) {
|
|
|
271
496
|
return;
|
|
272
497
|
}
|
|
273
498
|
sites['platform.tag'] = bindingsTag;
|
|
274
|
-
// D-06: read the REAL on-disk .gitlab-ci.yml and extract ref values.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// Extract `ref:` values from include blocks using a stateful line-scan (WR-02).
|
|
279
|
-
// Avoids false positives from `trigger:`, `needs:`, and other non-include stanzas.
|
|
280
|
-
const includeRefs = extractGitlabCiIncludeRefs(ciContent);
|
|
281
|
-
for (let refIdx = 0; refIdx < includeRefs.length; refIdx++) {
|
|
282
|
-
const key = refIdx === 0
|
|
283
|
-
? '.gitlab-ci.yml include ref'
|
|
284
|
-
: `.gitlab-ci.yml include ref[${refIdx + 1}]`;
|
|
285
|
-
sites[key] = includeRefs[refIdx];
|
|
286
|
-
}
|
|
287
|
-
// Extract `?ref=` values from module source lines.
|
|
288
|
-
const moduleRefRe = new RegExp(GITLAB_CI_MODULE_REF_RE.source, GITLAB_CI_MODULE_REF_RE.flags);
|
|
289
|
-
let m;
|
|
290
|
-
let modIdx = 0;
|
|
291
|
-
while ((m = moduleRefRe.exec(ciContent)) !== null) {
|
|
292
|
-
modIdx++;
|
|
293
|
-
const key = modIdx === 1
|
|
294
|
-
? '.gitlab-ci.yml module ?ref'
|
|
295
|
-
: `.gitlab-ci.yml module ?ref[${modIdx}]`;
|
|
296
|
-
sites[key] = m[1].trim();
|
|
297
|
-
}
|
|
298
|
-
}
|
|
499
|
+
// D-06: read the REAL on-disk .gitlab-ci.yml and extract ref values. Seed
|
|
500
|
+
// the sites record with the canonical tag first (above) so key order is
|
|
501
|
+
// unchanged, then merge in the collected CI sites.
|
|
502
|
+
Object.assign(sites, collectGitlabCiPinSites(root));
|
|
299
503
|
// Build distinct semver set across all pin-sites.
|
|
300
504
|
// `compareSemverCore === 0` normalises v-prefix differences (T-07-06).
|
|
301
505
|
const tagValues = Object.values(sites).filter(Boolean);
|