@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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-fixer.md +11 -0
  3. package/agents/pp-code-reviewer.md +77 -1
  4. package/agents/pp-executor.md +34 -1
  5. package/agents/pp-planner.md +24 -103
  6. package/commands/pp-ns-manage.md +2 -1
  7. package/commands/pp-vault-promote.md +31 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +5 -3
  10. package/pareto-skills/pp-brand/SKILL.md +1 -1
  11. package/pp-core/bin/lib/capability-registry.cjs +160 -3
  12. package/pp-core/bin/lib/capability-state.cjs +41 -3
  13. package/pp-core/bin/lib/check-command-router.cjs +473 -3
  14. package/pp-core/bin/lib/clusters.cjs +3 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +736 -0
  16. package/pp-core/bin/lib/git-provider.cjs +279 -0
  17. package/pp-core/bin/lib/init-contract.cjs +989 -0
  18. package/pp-core/bin/lib/init-interactive.cjs +183 -0
  19. package/pp-core/bin/lib/io.cjs +9 -0
  20. package/pp-core/bin/lib/org-profile.cjs +696 -0
  21. package/pp-core/bin/lib/platform-bindings.cjs +268 -64
  22. package/pp-core/bin/lib/practices-review-check.cjs +938 -0
  23. package/pp-core/bin/lib/vault-command-router.cjs +239 -2
  24. package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
  25. package/pp-core/bin/lib/vault-practices.cjs +982 -0
  26. package/pp-core/bin/lib/vault-write-back.cjs +842 -0
  27. package/pp-core/bin/lib/vault.cjs +170 -51
  28. package/pp-core/bin/pp-tools.cjs +225 -2
  29. package/pp-core/references/planner-checkpoints.md +72 -0
  30. package/pp-core/references/planner-tdd.md +55 -0
  31. package/pp-core/references/planner-vault-practices.md +67 -0
  32. package/pp-core/templates/practices/README.md +122 -0
  33. package/pp-core/templates/practices/error-handling.md +18 -0
  34. package/pp-core/templates/practices/minimal-comments.md +14 -0
  35. package/pp-core/templates/practices/naming-convention.md +18 -0
  36. package/pp-core/templates/practices/practice-template.md +23 -0
  37. package/pp-core/workflows/code-review.md +74 -18
  38. package/pp-core/workflows/discovery-sprint.md +1 -1
  39. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
  40. package/pp-core/workflows/init.md +137 -76
  41. package/pp-core/workflows/ship.md +1 -0
  42. package/pp-core/workflows/vault-write-back.md +213 -0
  43. package/scripts/gen-capability-registry.cjs +68 -8
  44. package/scripts/gen-platform-bindings-schema.cjs +148 -0
  45. package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
  46. package/scripts/lint-descriptions.cjs +3 -1
  47. package/scripts/lint-legacy-dir-name.cjs +5 -0
  48. 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
- * The core regexes below MUST mirror the `pattern` strings in
27
- * `schemas/platform-bindings.schema.json`; the schema↔validator coupling test
28
- * machine-enforces this so neither side can drift silently.
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
- // Core load-bearing patterns (D-01). These MUST mirror the `pattern` fields in
45
- // schemas/platform-bindings.schema.json — the coupling test fails if they drift.
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
- const PLATFORM_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
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
- // Provisional vault repo pattern (lenient): only enforced if client.vaultRepo is present.
52
- // MUST mirror schemas/platform-bindings.schema.json vaultRepo.pattern byte-for-byte —
53
- // the schema↔validator coupling test machine-enforces this so neither side can drift.
54
- // ReDoS note (T-11-01): anchored at both ends; single non-overlapping + quantifiers over
55
- // disjoint character classes — no nested/ambiguous quantifier, linear complexity only.
56
- const VAULT_REPO_RE = /^[\w.-]+(\/[\w.-]+)+$/;
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 + tag.
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 "group/project" path');
168
+ errors.push('platform.repo: missing or not a host-relative path of two or more segments');
84
169
  }
85
- if (typeof platform.tag !== 'string' || !PLATFORM_TAG_RE.test(platform.tag)) {
86
- errors.push('platform.tag: missing or not a "v<semver>" tag');
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
- if (typeof o.client.vaultRepo !== 'string' || !VAULT_REPO_RE.test(o.client.vaultRepo)) {
100
- errors.push('client.vaultRepo: present but not a valid nested group/project path (e.g. pareto4-clients/acme/acme-vault)');
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 `ref:` values that belong to the top-level `include:` block in a
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: ...]`). It does NOT
169
- * capture `ref:` values from `trigger:`, `needs:`, or other non-include stanzas,
170
- * because those appear under job-level keys (indent = 0) which close the include
171
- * context. May mis-classify exotic YAML (flow mappings, anchors, multi-document
172
- * files). Uses no YAML parser to avoid runtime dependencies (T-07-04).
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 `ref:` values found inside the `include:` block.
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
- const REF_LINE_RE = /^ref:\s+['"]?([^\s'"#,\]]+)['"]?/;
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: <value>` line.
324
+ // Within the include block, capture any `ref:` or `component:` pin line.
194
325
  if (inIncludeBlock) {
195
- const m = trimmed.match(REF_LINE_RE);
196
- if (m) {
197
- refs.push(m[1].trim());
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 → output({present:false}) exit 0 (inert, D-07)
211
- * internal mismatch (>1 distinct) → output({..., status:'block'}) exit 0 (D-04)
212
- * all-agree, behind --latest-tag → output({..., status:'warn'}) exit 0 (D-05)
213
- * all-agree, at/ahead of latest → output({..., status:'pass'}) exit 0
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
- // WR-01: guard on a missing/empty canonical tag — without it, the function
260
- // would fall through to emit status:'pass' with zero pin-sites, misleadingly
261
- // signalling a correct pin on a binding that validateBindings would reject.
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
- const gitlabCiPath = node_path_1.default.join(root, '.gitlab-ci.yml');
276
- if (node_fs_1.default.existsSync(gitlabCiPath)) {
277
- const ciContent = node_fs_1.default.readFileSync(gitlabCiPath, 'utf-8');
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);