@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
@@ -0,0 +1,696 @@
1
+ "use strict";
2
+ /**
3
+ * src/org-profile.cts — the org profile resolver (milestone v1.11.0, Phase 33-01).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the git-ignored `pp-core/bin/lib/org-profile.cjs`; do NOT
7
+ * hand-edit the compiled output. Behaviour is locked by
8
+ * `tests/org-profile.test.cjs`.
9
+ *
10
+ * This module resolves an OPTIONAL, operator-named npm package — the "org
11
+ * profile" — that carries a small set of vault/platform coordinates for a
12
+ * single operator's projects. The package name lives entirely in the
13
+ * consuming project's own `.planning/config.json`; pp-core's own source
14
+ * never names one (ORG-01, SC1). An install that configures no such
15
+ * pointer resolves nothing, warns about nothing, and probes no filesystem
16
+ * beyond the one config read every project already pays for (ORG-02).
17
+ *
18
+ * It imports ONLY node builtins (`node:fs`, `node:path`) at the top level —
19
+ * no `./vault.cjs`, `./vault-coordinates.cjs`, `./vault-write-back.cjs`,
20
+ * `./io.cjs`, `./shell-command-projection.cjs`. This is load-bearing: in
21
+ * 33-03 BOTH `src/git-provider.cts` and `src/vault-coordinates.cts` import
22
+ * THIS module at their own top level. If this module imported either of
23
+ * them back, that pair would form a load-time require cycle in the
24
+ * CommonJS graph that `require` loads — the exact hazard the cycle-avoidance
25
+ * comments at `src/git-provider.cts:9-23` and
26
+ * `src/vault-coordinates.cts:22-28` exist to prevent, and the reason both
27
+ * of those files write their own inline `cfgGet` instead of importing one
28
+ * from each other.
29
+ *
30
+ * The one exception is a CALL-TIME lazy `require('./git-provider.cjs')`
31
+ * inside `profileValueDisposition`, executed only when a profile value is
32
+ * actually being validated, never at module load. This is the same
33
+ * lazy-require seam `src/vault-command-router.cts` already uses for
34
+ * `./vault.cjs` and `./vault-write-back.cjs`, and it is safe here precisely
35
+ * because `git-provider.cjs` has no top-level dependency on this module —
36
+ * by the time any value is being validated, that module is either already
37
+ * in the require cache or loads cleanly on its own. D-10 requires reusing
38
+ * the shared shape predicates (`normalizeProvider`, `PROVIDERS`) rather
39
+ * than writing a second regex; the lazy seam is how a zero-import leaf can
40
+ * still do that.
41
+ *
42
+ * This file's own prose and code deliberately never name a concrete org
43
+ * profile package or any client — the pointer is described only as "the
44
+ * npm package name an operator configures". `tests/org-profile.test.cjs`
45
+ * proves this with an automated scan (SC1).
46
+ */
47
+ var __importDefault = (this && this.__importDefault) || function (mod) {
48
+ return (mod && mod.__esModule) ? mod : { "default": mod };
49
+ };
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.HOST_FORBIDDEN_CHARS = exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS = exports.ORG_PROFILE_SEED_KEYS = exports.ORG_PROFILE_SEED_SECTION = exports.ORG_PROFILE_ALLOWLIST = exports.ORG_PROFILE_DATA_FILE = exports.ORG_PROFILE_CONFIG_KEY = void 0;
52
+ exports.resolveOrgProfileDocument = resolveOrgProfileDocument;
53
+ exports.resolveOrgProfile = resolveOrgProfile;
54
+ exports.readProfileValue = readProfileValue;
55
+ const node_fs_1 = __importDefault(require("node:fs"));
56
+ const node_path_1 = __importDefault(require("node:path"));
57
+ // ─── Constants ───────────────────────────────────────────────────────────────
58
+ /** The dotted `.planning/config.json` key naming the profile package. Deliberately
59
+ * top-level, NOT nested under `vault.`, because the profile also supplies
60
+ * `platform.host` (D-02). */
61
+ exports.ORG_PROFILE_CONFIG_KEY = 'org.profile';
62
+ /** The fixed filename read from the resolved package's own directory — never the
63
+ * package's `main`/`exports` entry, and never loaded via `require` (D-08). */
64
+ exports.ORG_PROFILE_DATA_FILE = 'org-profile.json';
65
+ /**
66
+ * D-06's fixed allowlist: a profile may contribute exactly these five dotted
67
+ * config keys. Entry order is not contractual; membership is. Anything else
68
+ * present in a profile document is dropped with a bounded warning.
69
+ */
70
+ exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
71
+ 'vault.provider',
72
+ 'vault.fallback_repo',
73
+ 'vault.fallback_ref',
74
+ 'vault.fallback_host',
75
+ 'platform.host',
76
+ ]);
77
+ /**
78
+ * The single top-level key naming the Phase 34 init-only seed section.
79
+ *
80
+ * This namespace is read ONLY by the Phase 34 bindings composer
81
+ * (`pp-tools query compose-bindings`, invoked by `/pp-init`) — never by
82
+ * `resolveOrgProfile`, `readProfileValue`, or any of their consumers
83
+ * (`resolveProvider`, `resolvePlatformHost`, `resolveSharedCoordinates`). It
84
+ * is deliberately ABSENT from `ORG_PROFILE_ALLOWLIST` (D-01): extending the
85
+ * runtime allowlist to cover it would reopen Phase 33 D-05's threat —
86
+ * `assertVaultWriteTarget` decides by the resolved `(repo, host)` pair, so a
87
+ * key that layers at RUNTIME is a key an `npm update` can silently move
88
+ * under the write guard. A seed value only ever reaches the guard via a
89
+ * file a human ran `/pp-init` to produce, reviewed, and committed — never
90
+ * directly from the package.
91
+ */
92
+ exports.ORG_PROFILE_SEED_SECTION = 'bindings';
93
+ /**
94
+ * The five dotted leaf keys the seed section may carry, each prefixed with
95
+ * `ORG_PROFILE_SEED_SECTION`. Membership is contractual; entry ORDER is not.
96
+ *
97
+ * This array is a DESCRIPTION of the section's shape, not a validator —
98
+ * value guards (required-vs-optional, host/clone-target forms) live in the
99
+ * Phase 34 bindings composer, not here.
100
+ */
101
+ exports.ORG_PROFILE_SEED_KEYS = Object.freeze([
102
+ `${exports.ORG_PROFILE_SEED_SECTION}.platform.repo`,
103
+ `${exports.ORG_PROFILE_SEED_SECTION}.client.vaultRepo`,
104
+ `${exports.ORG_PROFILE_SEED_SECTION}.client.vaultRef`,
105
+ `${exports.ORG_PROFILE_SEED_SECTION}.client.vaultHost`,
106
+ `${exports.ORG_PROFILE_SEED_SECTION}.provisioning.group`,
107
+ ]);
108
+ /**
109
+ * D-10's four machine-specific clone-target forms, copied VERBATIM (not
110
+ * re-authored) from `LITERAL_CLONE_TARGET_PATTERNS` in `src/git-provider.cts`
111
+ * — the explicit-relative, parent-relative, home-relative and Windows
112
+ * drive-path forms. These are valid-but-warned when an operator types them
113
+ * (Phase 32 D-04) but REJECTED outright when a profile package supplies
114
+ * them, because a human chose one deliberately and a package did not.
115
+ * `tests/org-profile.test.cjs` pins that every entry here is `===`-identical
116
+ * to a member of the upstream array, so an upstream edit goes red here
117
+ * instead of silently desyncing. The absolute-unix-path form, the
118
+ * scheme-URL form and the scp-style form are deliberately NOT among these
119
+ * four — D-10 names exactly four machine-specific forms, and everything
120
+ * else `isLiteralCloneTarget` accepts stays accepted from a profile.
121
+ */
122
+ exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS = Object.freeze([
123
+ '^\\.\\/', // explicit relative — ./path
124
+ '^\\.\\.\\/', // parent-relative — ../path
125
+ '^~', // home-relative — ~ or ~/path
126
+ '^[A-Za-z]:[\\\\/]', // Windows drive path — C:\ or C:/
127
+ ]);
128
+ // Compiled once at module load, without the global flag (a global regex
129
+ // carries `lastIndex` state across `test` calls and would make the
130
+ // predicate answer differently on repeated identical inputs) — the same
131
+ // convention `src/git-provider.cts` uses for `LITERAL_CLONE_TARGET_PATTERNS`.
132
+ const compiledRejectedCloneTargetPatterns = exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS.map((source) => new RegExp(source));
133
+ /**
134
+ * Characters a profile-supplied host may never contain (T-33-04).
135
+ *
136
+ * `vault.fallback_host` and `platform.host` are interpolated by
137
+ * `composeCloneTarget` as the AUTHORITY of `https://{host}/{repo}`, so this
138
+ * guard has to do more than keep a path out of the value — it has to keep
139
+ * the value from escaping the authority component at all.
140
+ *
141
+ * `@` is the load-bearing entry: `dev.azure.com@attacker.example.com`
142
+ * composes to `https://dev.azure.com@attacker.example.com/…`, which every
143
+ * RFC-3986 parser reads as userinfo `dev.azure.com` at host
144
+ * `attacker.example.com`. Since a profile is dependency-supplied, that is a
145
+ * silent `npm update`-reachable redirect of the clone — and of the
146
+ * `(repo, host)` pair `assertVaultWriteTarget` decides on. `?` and `#`
147
+ * terminate the authority early. `/` and `\` carry a path.
148
+ *
149
+ * `:` is deliberately absent — a port is legitimate for a self-hosted
150
+ * instance and cannot change WHICH host is contacted.
151
+ */
152
+ exports.HOST_FORBIDDEN_CHARS = Object.freeze([
153
+ '/',
154
+ '\\',
155
+ '@',
156
+ '?',
157
+ '#',
158
+ ]);
159
+ const PROFILE_FLATTEN_DEPTH_CAP = 4;
160
+ const PROFILE_FLATTEN_LEAF_CAP = 200;
161
+ const PROFILE_IGNORED_WARNING_CAP = 5;
162
+ // ─── Private helpers (deliberate per-module duplication) ────────────────────
163
+ /**
164
+ * Read a dotted config key from `.planning/config.json`, returning
165
+ * `fallback` on any miss/unreadable-file/parse-error/non-object. Never
166
+ * throws. Byte-for-byte mirror of `cfgGet` in `src/vault-coordinates.cts`
167
+ * (lines 114-125), which itself mirrors the canonical original at
168
+ * `src/git-provider.cts` (lines 78-89). Written inline rather than imported
169
+ * — see the module header for why (the cycle this module must not create).
170
+ */
171
+ function cfgGet(cwd, key, fallback) {
172
+ try {
173
+ const cfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8'));
174
+ const v = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cfg);
175
+ return v === undefined ? fallback : v;
176
+ }
177
+ catch {
178
+ return fallback;
179
+ }
180
+ }
181
+ /** Mirror of `isObject` in `src/vault-coordinates.cts` (lines 127-129). */
182
+ function isObject(v) {
183
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
184
+ }
185
+ /**
186
+ * Bound a rejected/untrusted config or profile value before it is echoed
187
+ * into a warning. Byte-for-byte mirror of `sanitizeForWarning` in
188
+ * `src/git-provider.cts` (lines 98-100) / `src/vault-coordinates.cts`
189
+ * (lines 155-157): strip CR/LF, collapse internal whitespace, trim,
190
+ * truncate to 40 characters (T-31-05, T-33-05).
191
+ */
192
+ function sanitizeForWarning(value) {
193
+ return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
194
+ }
195
+ /** Bound a whole warning string to 200 characters — every warning this module
196
+ * emits goes through this (T-31-05). */
197
+ function bound(warning) {
198
+ return warning.slice(0, 200);
199
+ }
200
+ /**
201
+ * Describe an arbitrary config-supplied value as a string for a warning,
202
+ * without ever calling the ambient `toString`/template-literal coercion on
203
+ * an object (which would silently produce `"[object Object]"`). Strings
204
+ * pass through; numbers/booleans coerce safely; everything else goes
205
+ * through `JSON.stringify`, falling back to `Object.prototype.toString`
206
+ * (an explicit, guaranteed-safe call, not the value's own) on failure.
207
+ */
208
+ function describeUnknown(value) {
209
+ if (typeof value === 'string')
210
+ return value;
211
+ if (typeof value === 'number' || typeof value === 'boolean')
212
+ return String(value);
213
+ if (value === null)
214
+ return 'null';
215
+ try {
216
+ const serialized = JSON.stringify(value);
217
+ return typeof serialized === 'string' ? serialized : Object.prototype.toString.call(value);
218
+ }
219
+ catch {
220
+ return Object.prototype.toString.call(value);
221
+ }
222
+ }
223
+ // ─── resolveGlobalPackageRoot ────────────────────────────────────────────────
224
+ /**
225
+ * D-03's "global" half of the two-path resolution ladder, as a pure
226
+ * heuristic with NO subprocess spawn (this repo avoids spawning a
227
+ * subprocess for a computation that does not strictly require one):
228
+ *
229
+ * 1. `NPM_CONFIG_PREFIX` / `npm_config_prefix`, when set to a non-blank
230
+ * string: `<prefix>/node_modules` on `win32`, `<prefix>/lib/node_modules`
231
+ * otherwise.
232
+ * 2. Otherwise, derived from `process.execPath`: with
233
+ * `execDir = path.dirname(process.execPath)`, `path.join(execDir,
234
+ * 'node_modules')` on `win32`, `path.resolve(execDir, '..', 'lib',
235
+ * 'node_modules')` otherwise.
236
+ *
237
+ * The candidate is returned only when `fs.existsSync(candidate)`;
238
+ * otherwise `null`.
239
+ *
240
+ * Accepted residual: an nvm/Volta-style layout that honours neither
241
+ * `NPM_CONFIG_PREFIX` nor places the global tree beside the node binary
242
+ * will simply not resolve a globally-installed profile — this degrades
243
+ * per D-09 (falls through to the no-profile behaviour with a warning)
244
+ * rather than failing.
245
+ *
246
+ * pp-core's own install directory is deliberately NOT a candidate here:
247
+ * the AI-runtime install is a bare directory copy with no `package.json`
248
+ * and no `node_modules` of its own, so resolving relative to it would
249
+ * never find a sibling package.
250
+ */
251
+ function resolveGlobalPackageRoot() {
252
+ try {
253
+ const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
254
+ let candidate;
255
+ if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
256
+ const prefix = prefixEnv.trim();
257
+ candidate =
258
+ process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
259
+ }
260
+ else {
261
+ const execDir = node_path_1.default.dirname(process.execPath);
262
+ candidate =
263
+ process.platform === 'win32'
264
+ ? node_path_1.default.join(execDir, 'node_modules')
265
+ : node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
266
+ }
267
+ return node_fs_1.default.existsSync(candidate) ? candidate : null;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ // ─── resolveProfilePackage ───────────────────────────────────────────────────
274
+ /**
275
+ * D-03's two-path resolution ladder. Attempt `require.resolve(pkgName +
276
+ * '/package.json', { paths: [cwd] })` first and report `source: 'project'`;
277
+ * on failure attempt the same with `paths: [globalRoot]` when a global root
278
+ * was found and report `source: 'global'`; otherwise `null`. The
279
+ * project-local hit always wins when both would resolve — the global
280
+ * attempt is only ever reached when the project attempt threw.
281
+ *
282
+ * Mirrors `resolveEslintCli` in `src/prohibition-enforcement.cts` (lines
283
+ * 220-228): the same try/catch-returns-null shape, one level removed
284
+ * (resolving a data file's sibling `package.json`, not a CLI entry point).
285
+ */
286
+ function resolveProfilePackage(pkgName, cwd, globalRoot) {
287
+ try {
288
+ return { pkgJsonPath: require.resolve(`${pkgName}/package.json`, { paths: [cwd] }), source: 'project' };
289
+ }
290
+ catch {
291
+ // fall through to the global attempt below
292
+ }
293
+ if (globalRoot) {
294
+ try {
295
+ return { pkgJsonPath: require.resolve(`${pkgName}/package.json`, { paths: [globalRoot] }), source: 'global' };
296
+ }
297
+ catch {
298
+ // fall through to null below
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+ /**
304
+ * Flatten a parsed profile document to dotted leaf paths, recursing only
305
+ * through plain objects. Bounded on two axes (T-33-06, denial-of-service):
306
+ * a depth cap of 4 and a visited-leaf cap of 200. A document exceeding
307
+ * either cap stops flattening immediately and reports exactly one bounded
308
+ * warning — the caller never sees more than one such warning regardless of
309
+ * how far over either cap the document goes.
310
+ */
311
+ function flattenProfileDocument(doc) {
312
+ const leaves = [];
313
+ let cappedWarning = null;
314
+ function walk(obj, prefix, depth) {
315
+ if (cappedWarning)
316
+ return;
317
+ for (const k of Object.keys(obj)) {
318
+ if (cappedWarning)
319
+ return;
320
+ const dotted = prefix ? `${prefix}.${k}` : k;
321
+ const v = obj[k];
322
+ if (isObject(v)) {
323
+ if (depth + 1 > PROFILE_FLATTEN_DEPTH_CAP) {
324
+ cappedWarning = 'org profile document was too deeply nested to inspect fully — stopped flattening.';
325
+ return;
326
+ }
327
+ walk(v, dotted, depth + 1);
328
+ }
329
+ else {
330
+ if (leaves.length >= PROFILE_FLATTEN_LEAF_CAP) {
331
+ cappedWarning = 'org profile document was too large to inspect fully — stopped flattening.';
332
+ return;
333
+ }
334
+ leaves.push({ key: dotted, value: v });
335
+ }
336
+ }
337
+ }
338
+ walk(doc, '', 0);
339
+ return { leaves, cappedWarning };
340
+ }
341
+ // ─── profileValueDisposition (D-10) ──────────────────────────────────────────
342
+ /**
343
+ * The D-10 guard: profile-supplied values pass the same scalar guard every
344
+ * allowlisted key gets, then a stricter per-key disposition — expressed as
345
+ * a DISPOSITION over the shared shape predicates, never as a parallel
346
+ * regex (D-10's "one source of shape truth" requirement).
347
+ *
348
+ * Scalar guard, applied to every key: the value must be a string; its
349
+ * trimmed form must be non-empty; the UNTRIMMED value must carry no ASCII
350
+ * control character; the TRIMMED value must carry no whitespace (so
351
+ * leading/trailing padding — e.g. `' azure '` — is tolerated, matching
352
+ * every other config reader in this seam, but an internal space or newline
353
+ * is not); and the trimmed value must not start with `-` (the T-29-26
354
+ * flag-shaped refusal `isLiteralCloneTarget` already applies at
355
+ * `src/git-provider.cts:186`, restated here as a disposition because a
356
+ * plain slug beginning with `-` is not a literal clone target and would
357
+ * otherwise pass `isLiteralCloneTarget` unmolested). The value carried
358
+ * forward past the scalar guard is always the trimmed string.
359
+ *
360
+ * Per-key dispositions:
361
+ * - `vault.provider`: accept only when `normalizeProvider(value)` (obtained
362
+ * via the call-time lazy `require('./git-provider.cjs')`, never a
363
+ * top-level import) returns non-null, and carry forward the NORMALIZED
364
+ * id. Reason on failure names the supported ids via `PROVIDERS`.
365
+ * - `vault.fallback_repo`: reject when the trimmed value matches any of
366
+ * `PROFILE_REJECTED_CLONE_TARGET_PATTERNS`. Everything else — an
367
+ * absolute path, a scheme URL, scp-style `user@host:path`, or a plain
368
+ * `group/project` slug — is accepted UNCHANGED (T-30-08: never rewritten,
369
+ * normalised or stripped).
370
+ * - `vault.fallback_host` / `platform.host`: reject when the trimmed value
371
+ * contains any of `/` `\` `@` `?` `#` — these values are interpolated
372
+ * into a clone URL by `composeCloneTarget` as the AUTHORITY component,
373
+ * so the guard has to keep the value from carrying a path AND from
374
+ * escaping the authority at all. `@` is the load-bearing one: a host of
375
+ * `dev.azure.com@attacker.example.com` composes to
376
+ * `https://dev.azure.com@attacker.example.com/…`, which every RFC-3986
377
+ * parser reads as userinfo `dev.azure.com` at host `attacker.example.com`
378
+ * — a dependency-supplied value silently redirecting the clone, and with
379
+ * it the `(repo, host)` pair `assertVaultWriteTarget` decides on. `?` and
380
+ * `#` likewise terminate the authority early. None is legal in a
381
+ * hostname. `:` is deliberately still allowed: a port is legitimate for
382
+ * a self-hosted instance and cannot change WHICH host is contacted.
383
+ * - `vault.fallback_ref`: the scalar guard alone.
384
+ *
385
+ * Wrapped so that any unexpected fault (including a `require` failure on
386
+ * the lazy seam) degrades to rejecting the value rather than throwing
387
+ * (D-09) — this function never throws.
388
+ */
389
+ function profileValueDisposition(key, value) {
390
+ try {
391
+ if (typeof value !== 'string') {
392
+ return { ok: false, reason: 'value must be a string' };
393
+ }
394
+ const trimmed = value.trim();
395
+ if (trimmed === '') {
396
+ return { ok: false, reason: 'value must not be blank' };
397
+ }
398
+ if (/[\x00-\x1f\x7f]/.test(value)) {
399
+ return { ok: false, reason: 'value must not contain control characters' };
400
+ }
401
+ if (/\s/.test(trimmed)) {
402
+ return { ok: false, reason: 'value must not contain whitespace' };
403
+ }
404
+ if (trimmed.startsWith('-')) {
405
+ return { ok: false, reason: 'value must not begin with "-"' };
406
+ }
407
+ if (key === 'vault.provider') {
408
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
409
+ const gp = require('./git-provider.cjs');
410
+ const normalized = gp.normalizeProvider(trimmed);
411
+ if (normalized === null) {
412
+ return { ok: false, reason: `unsupported provider — supported: ${gp.PROVIDERS.join(', ')}` };
413
+ }
414
+ return { ok: true, value: normalized };
415
+ }
416
+ if (key === 'vault.fallback_repo') {
417
+ if (compiledRejectedCloneTargetPatterns.some((re) => re.test(trimmed))) {
418
+ return { ok: false, reason: 'a machine-specific path is not accepted from an org profile' };
419
+ }
420
+ return { ok: true, value: trimmed };
421
+ }
422
+ if (key === 'vault.fallback_host' || key === 'platform.host') {
423
+ // Authority-escape guard. `/` and `\` keep a path out of the value;
424
+ // `@` `?` `#` keep the value from redirecting or truncating the
425
+ // authority component it is interpolated into. See the doc block.
426
+ for (const ch of exports.HOST_FORBIDDEN_CHARS) {
427
+ if (trimmed.includes(ch)) {
428
+ return {
429
+ ok: false,
430
+ reason: `a host must not contain ${ch} — it must be a bare host, optionally with a port`,
431
+ };
432
+ }
433
+ }
434
+ return { ok: true, value: trimmed };
435
+ }
436
+ // vault.fallback_ref: the scalar guard alone.
437
+ return { ok: true, value: trimmed };
438
+ }
439
+ catch {
440
+ return { ok: false, reason: 'value could not be validated' };
441
+ }
442
+ }
443
+ // ─── resolveOrgProfileDocument (D-01) ────────────────────────────────────────
444
+ /**
445
+ * Resolve the org profile configured (if any) for the project at `cwd`, and
446
+ * return the RAW parsed document — unflattened, unfiltered, including any
447
+ * `ORG_PROFILE_SEED_SECTION` the D-06 allowlist deliberately excludes.
448
+ *
449
+ * This is `resolveOrgProfile`'s own pointer→package→document walk (its
450
+ * former steps 1-3), extracted so the two have exactly ONE implementation of
451
+ * that walk (the WR-01 defect class this repo has already paid for twice) —
452
+ * `resolveOrgProfile` calls this function for its own steps 1-3 rather than
453
+ * duplicating them. Consumers that need the D-06-filtered `contributes` map
454
+ * still want `resolveOrgProfile`; this function exists for the Phase 34
455
+ * bindings composer, which needs the seed section `resolveOrgProfile`
456
+ * deliberately never surfaces.
457
+ *
458
+ * The `_globalRoot` option is the repo's `_`-prefixed test seam convention
459
+ * (as used by `_vault` / `_exec` / `_writeBack` in
460
+ * `src/vault-command-router.cts`); production callers omit it and the
461
+ * module calls `resolveGlobalPackageRoot()`. Passing `_globalRoot`
462
+ * explicitly (including `null`, to simulate "no global root found")
463
+ * overrides the production heuristic for that one call.
464
+ *
465
+ * Never throws (D-09): any fault anywhere in this function degrades to the
466
+ * fully-absent shape plus one bounded warning, exactly as if no profile
467
+ * were configured. The document is read only via `fs.readFileSync` +
468
+ * `JSON.parse`, never `require` (D-08).
469
+ */
470
+ function resolveOrgProfileDocument(cwd, opts) {
471
+ const absentDocShape = (warnings = []) => ({
472
+ configured: null,
473
+ resolved: false,
474
+ packageName: null,
475
+ version: null,
476
+ source: 'none',
477
+ dataPath: null,
478
+ document: {},
479
+ warnings,
480
+ });
481
+ try {
482
+ // Step 1: read the pointer. An absent key (undefined) or a blank string
483
+ // is the fully-absent shape with ZERO warnings and no further
484
+ // filesystem access — this is the ORG-02 contract. A present
485
+ // non-string value is the one exception: same absent shape, plus
486
+ // exactly one bounded warning.
487
+ const rawPointer = cfgGet(cwd, exports.ORG_PROFILE_CONFIG_KEY, undefined);
488
+ if (rawPointer === undefined) {
489
+ return absentDocShape();
490
+ }
491
+ if (typeof rawPointer !== 'string') {
492
+ const echoed = sanitizeForWarning(describeUnknown(rawPointer));
493
+ return absentDocShape([
494
+ bound(`${exports.ORG_PROFILE_CONFIG_KEY} value "${echoed}" is not a string — ignoring, no org profile applied.`),
495
+ ]);
496
+ }
497
+ const pkgName = rawPointer.trim();
498
+ if (pkgName === '') {
499
+ return absentDocShape();
500
+ }
501
+ // Step 2: resolve the package (project, then global).
502
+ const globalRoot = opts && opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalPackageRoot();
503
+ const hit = resolveProfilePackage(pkgName, cwd, globalRoot);
504
+ if (!hit) {
505
+ const echoed = sanitizeForWarning(pkgName);
506
+ return {
507
+ ...absentDocShape(),
508
+ configured: pkgName,
509
+ warnings: [
510
+ bound(`org profile "${echoed}" was not found — resolution was attempted from the project and the global npm root and found nothing. Falling back to built-in defaults.`),
511
+ ],
512
+ };
513
+ }
514
+ const packageDir = node_path_1.default.dirname(hit.pkgJsonPath);
515
+ // Step 3: read the package's own version, then its data file. NEVER
516
+ // load the profile package or any file inside it via `require` (D-08) —
517
+ // only require.resolve (path resolution), fs.readFileSync and JSON.parse.
518
+ let version = null;
519
+ try {
520
+ const pkgJson = JSON.parse(node_fs_1.default.readFileSync(hit.pkgJsonPath, 'utf-8'));
521
+ version = isObject(pkgJson) && typeof pkgJson.version === 'string' ? pkgJson.version : null;
522
+ }
523
+ catch {
524
+ version = null;
525
+ }
526
+ const dataPath = node_path_1.default.join(packageDir, exports.ORG_PROFILE_DATA_FILE);
527
+ const warnings = [];
528
+ let doc = {};
529
+ try {
530
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(dataPath, 'utf-8'));
531
+ if (isObject(parsed)) {
532
+ doc = parsed;
533
+ }
534
+ else {
535
+ warnings.push(bound(`org profile "${pkgName}" data file is not a JSON object — no coordinates contributed.`));
536
+ }
537
+ }
538
+ catch {
539
+ warnings.push(bound(`org profile "${pkgName}" data file could not be read — no coordinates contributed.`));
540
+ }
541
+ return {
542
+ configured: pkgName,
543
+ resolved: true,
544
+ packageName: pkgName,
545
+ version,
546
+ source: hit.source,
547
+ dataPath,
548
+ document: doc,
549
+ warnings,
550
+ };
551
+ }
552
+ catch {
553
+ return absentDocShape(['org profile resolution failed unexpectedly — falling back to built-in defaults.']);
554
+ }
555
+ }
556
+ // ─── resolveOrgProfile ────────────────────────────────────────────────────────
557
+ /**
558
+ * Resolve the org profile configured (if any) for the project at `cwd`.
559
+ *
560
+ * Delegates its own steps 1-3 (pointer → package → raw document) to
561
+ * `resolveOrgProfileDocument` — see that function's doc comment for the
562
+ * `_globalRoot` test seam and the D-09 never-throws contract, both of which
563
+ * this function inherits unchanged.
564
+ *
565
+ * Never throws (D-09): any fault anywhere in this function degrades to the
566
+ * fully-absent shape plus one bounded warning, exactly as if no profile
567
+ * were configured.
568
+ */
569
+ function resolveOrgProfile(cwd, opts) {
570
+ const absentShape = (warnings = []) => ({
571
+ configured: null,
572
+ resolved: false,
573
+ packageName: null,
574
+ version: null,
575
+ source: 'none',
576
+ dataPath: null,
577
+ contributes: {},
578
+ ignored: [],
579
+ rejected: [],
580
+ warnings,
581
+ });
582
+ try {
583
+ const docResolution = resolveOrgProfileDocument(cwd, opts);
584
+ // Steps 1-3 absent (no pointer, non-string pointer, blank pointer, or
585
+ // package not found): carry the document resolution's `configured` and
586
+ // `warnings` through verbatim into the absent-shaped OrgProfileResolution.
587
+ if (!docResolution.resolved) {
588
+ return {
589
+ ...absentShape(docResolution.warnings),
590
+ configured: docResolution.configured,
591
+ };
592
+ }
593
+ const warnings = [...docResolution.warnings];
594
+ const doc = docResolution.document;
595
+ // Step 4: flatten and partition by the allowlist (D-06).
596
+ const { leaves, cappedWarning } = flattenProfileDocument(doc);
597
+ if (cappedWarning)
598
+ warnings.push(bound(cappedWarning));
599
+ const contributes = {};
600
+ const ignored = [];
601
+ const rejected = [];
602
+ let ignoredWarningCount = 0;
603
+ let ignoredOverflow = 0;
604
+ // Step 5: validate each allowlisted leaf (D-10); everything else is
605
+ // dropped with a capped warning list (D-06) — except the seed namespace
606
+ // (D-01), which is a KNOWN non-runtime namespace with its own composer
607
+ // (Phase 34), not an unknown key. Skipping it here means it never
608
+ // consumes the PROFILE_IGNORED_WARNING_CAP budget and never makes a
609
+ // seeded profile's `resolveProvider` / `vault status` calls noisier —
610
+ // and it changes no membership in ORG_PROFILE_ALLOWLIST.
611
+ for (const leaf of leaves) {
612
+ if (leaf.key === exports.ORG_PROFILE_SEED_SECTION || leaf.key.startsWith(`${exports.ORG_PROFILE_SEED_SECTION}.`)) {
613
+ continue;
614
+ }
615
+ if (!exports.ORG_PROFILE_ALLOWLIST.includes(leaf.key)) {
616
+ ignored.push(leaf.key);
617
+ if (ignoredWarningCount < PROFILE_IGNORED_WARNING_CAP) {
618
+ warnings.push(bound(`org profile key "${sanitizeForWarning(leaf.key)}" is not on the allowlist — ignored.`));
619
+ ignoredWarningCount += 1;
620
+ }
621
+ else {
622
+ ignoredOverflow += 1;
623
+ }
624
+ continue;
625
+ }
626
+ const disposition = profileValueDisposition(leaf.key, leaf.value);
627
+ if (disposition.ok) {
628
+ contributes[leaf.key] = disposition.value;
629
+ }
630
+ else {
631
+ rejected.push(leaf.key);
632
+ warnings.push(bound(`org profile key "${leaf.key}" rejected — ${disposition.reason}.`));
633
+ }
634
+ }
635
+ if (ignoredOverflow > 0) {
636
+ warnings.push(bound(`${ignoredOverflow} further org profile key(s) were ignored (not on the allowlist).`));
637
+ }
638
+ // Step 6: return. Nothing above throws for any input, but the whole
639
+ // body is still guarded below (D-09) so a fault in a helper this
640
+ // function forgot to wrap still yields the absent shape, not a crash.
641
+ return {
642
+ configured: docResolution.configured,
643
+ resolved: true,
644
+ packageName: docResolution.packageName,
645
+ version: docResolution.version,
646
+ source: docResolution.source,
647
+ dataPath: docResolution.dataPath,
648
+ contributes,
649
+ ignored,
650
+ rejected,
651
+ warnings,
652
+ };
653
+ }
654
+ catch {
655
+ return absentShape(['org profile resolution failed unexpectedly — falling back to built-in defaults.']);
656
+ }
657
+ }
658
+ // ─── readProfileValue (D-07) ──────────────────────────────────────────────────
659
+ /**
660
+ * D-07's key-keyed profile layer. This function REPLACES the tier-keyed
661
+ * `orgProfileCoordinates(cwd, tier)` stub Phase 31 froze in
662
+ * `src/vault-coordinates.cts` — a deliberate supersession, not an
663
+ * extension, recorded here and in `src/vault-coordinates.cts`'s own doc
664
+ * comment. D-06's allowlist is a set of config KEYS, not tiers, so one
665
+ * key-keyed function serves all three consuming resolvers
666
+ * (`resolveSharedCoordinates`, `resolvePlatformHost`, and
667
+ * `resolveProvider`), where `provider` and `platform.host` do not fit a
668
+ * per-tier return shape at all. `resolveClientCoordinates` needs no direct
669
+ * call: its host chain reaches this function transitively through
670
+ * `resolvePlatformHost`.
671
+ *
672
+ * When `key` is not in `ORG_PROFILE_ALLOWLIST`, returns
673
+ * `{ value: undefined, warnings: [], source: 'none' }` immediately with NO
674
+ * filesystem access — a caller asking for a non-allowlisted key is a
675
+ * programming error in this module's own consumers, not an untrusted-input
676
+ * case. Otherwise resolves the profile via `resolveOrgProfile(cwd, opts)`
677
+ * and returns that key's `contributes` entry (or `undefined`), the
678
+ * resolution's `warnings`, and its `source`.
679
+ *
680
+ * The profile is re-read from disk on every call, matching every sibling
681
+ * resolver in this seam (`cfgGet`, `readBindings`, `resolveProvider`) —
682
+ * this module introduces no caching. Consumers that call this more than
683
+ * once per resolution MUST de-duplicate the returned warnings by exact
684
+ * string before folding them into their own warning list.
685
+ */
686
+ function readProfileValue(cwd, key, opts) {
687
+ if (!exports.ORG_PROFILE_ALLOWLIST.includes(key)) {
688
+ return { value: undefined, warnings: [], source: 'none' };
689
+ }
690
+ const resolution = resolveOrgProfile(cwd, opts);
691
+ return {
692
+ value: resolution.contributes[key],
693
+ warnings: resolution.warnings,
694
+ source: resolution.source,
695
+ };
696
+ }