@deftai/directive-core 0.97.0 → 0.98.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 (47) hide show
  1. package/dist/authz/classify.js +291 -0
  2. package/dist/check/cached-orchestrator.d.ts +5 -0
  3. package/dist/check/cached-orchestrator.js +18 -1
  4. package/dist/check/gate-lists.d.ts +20 -0
  5. package/dist/check/gate-lists.js +46 -9
  6. package/dist/check/index.d.ts +1 -1
  7. package/dist/check/index.js +1 -1
  8. package/dist/check/orchestrator.d.ts +4 -0
  9. package/dist/check/orchestrator.js +4 -0
  10. package/dist/doctor/checks.d.ts +7 -0
  11. package/dist/doctor/checks.js +83 -0
  12. package/dist/hooks/dispatcher.d.ts +5 -0
  13. package/dist/hooks/dispatcher.js +54 -4
  14. package/dist/init-deposit/hygiene.d.ts +70 -1
  15. package/dist/init-deposit/hygiene.js +582 -8
  16. package/dist/init-deposit/scaffold.js +277 -4
  17. package/dist/init-deposit/skill-discovery-deposit.js +15 -0
  18. package/dist/policy/check-resume.d.ts +72 -0
  19. package/dist/policy/check-resume.js +253 -0
  20. package/dist/policy/coverage-check-resume-presets.d.ts +46 -0
  21. package/dist/policy/coverage-check-resume-presets.js +228 -0
  22. package/dist/policy/coverage-debt.d.ts +76 -0
  23. package/dist/policy/coverage-debt.js +262 -0
  24. package/dist/policy/index.d.ts +3 -0
  25. package/dist/policy/index.js +50 -21
  26. package/dist/release/auto-hatch.d.ts +114 -0
  27. package/dist/release/auto-hatch.js +301 -0
  28. package/dist/release/coverage-debt-ledger.d.ts +22 -0
  29. package/dist/release/coverage-debt-ledger.js +157 -0
  30. package/dist/release/index.d.ts +3 -0
  31. package/dist/release/index.js +3 -0
  32. package/dist/release/pipeline.js +164 -12
  33. package/dist/release/suite-stamp.d.ts +44 -0
  34. package/dist/release/suite-stamp.js +133 -0
  35. package/dist/release/types.d.ts +19 -0
  36. package/dist/session/coverage-check-resume-nudge.d.ts +34 -0
  37. package/dist/session/coverage-check-resume-nudge.js +66 -0
  38. package/dist/session/index.d.ts +1 -0
  39. package/dist/session/index.js +1 -0
  40. package/dist/session/session-start.js +21 -0
  41. package/dist/triage/classify/label-mirror.d.ts +31 -1
  42. package/dist/triage/classify/label-mirror.js +78 -6
  43. package/dist/triage/help/registry-data.d.ts +6 -6
  44. package/dist/triage/help/registry-data.js +12 -3
  45. package/dist/vbrief-validate/plan-hooks.d.ts +4 -0
  46. package/dist/vbrief-validate/plan-hooks.js +54 -0
  47. package/package.json +3 -3
@@ -8,7 +8,7 @@
8
8
  * and scope briefs are never installer-managed; if they reappear in
9
9
  * `installerManagedMatchers()`, unit tests and deposit-time assert fail closed.
10
10
  *
11
- * Refs #1576, #1453, #1430, #3029, #3030, #3127, #3117.
11
+ * Refs #1576, #1453, #1430, #3029, #3030, #3127, #3117, #3193.
12
12
  */
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { existsSync, readdirSync, rmSync } from "node:fs";
@@ -82,11 +82,12 @@ export function installerManagedMatchers() {
82
82
  { exact: CODEQL_CONFIG_REL },
83
83
  { exact: CORE_GUARD_WORKFLOW_REL },
84
84
  { exact: "Taskfile.yml" },
85
- // Upgrade co-travel unit (#3127): npm pin + lock follow-through and the
86
- // freshness live stamp (#3117) are part of a normal framework upgrade, not
87
- // product feature work. Path-level allow for v1 (stricter “only
88
- // @deftai/directive version changed” is a follow-on if needed).
89
- // Mixing .deft/core/** with true app/product paths still fails (#1430).
85
+ // Upgrade co-travel unit (#3127 / #3193): path-allow package/lock + GENERATION
86
+ // so they are not auto-classified as "app". When mixed with .deft/core/**, the
87
+ // deposited guard + classifyMixedCoreAndAppContentAware additionally require
88
+ // package.json to be @deftai/directive* dependency-key pin-only and lockfiles
89
+ // to be pin follow-through (not unrelated direct product deps). GENERATION
90
+ // stays path-only (#3117). True app/product paths still fail (#1430).
90
91
  { exact: "package.json" },
91
92
  { exact: "package-lock.json" },
92
93
  { exact: "pnpm-lock.yaml" },
@@ -183,10 +184,541 @@ export function installerManagedGuardEre() {
183
184
  export function isInstallerManagedPath(path) {
184
185
  return matchesInstallerManaged(path, installerManagedMatchers());
185
186
  }
187
+ /** Paths that path-allowlist for upgrade co-travel but need content checks with core (#3193). */
188
+ export const UPGRADE_PIN_CONTENT_PATHS = [
189
+ "package.json",
190
+ "package-lock.json",
191
+ "pnpm-lock.yaml",
192
+ "yarn.lock",
193
+ ];
194
+ /** package.json dependency map fields that may hold @deftai/directive* pins. */
195
+ export const PACKAGE_JSON_DEP_FIELDS = [
196
+ "dependencies",
197
+ "devDependencies",
198
+ "optionalDependencies",
199
+ "peerDependencies",
200
+ ];
201
+ /**
202
+ * True when `name` is an `@deftai/directive*` dependency **key** (#3193).
203
+ * Substring hits in scripts/settings values do not qualify.
204
+ */
205
+ export function isDirectiveDependencyKey(name) {
206
+ return name.startsWith("@deftai/directive");
207
+ }
208
+ function deepEqualJson(a, b) {
209
+ if (a === b)
210
+ return true;
211
+ if (a === null || b === null)
212
+ return a === b;
213
+ if (typeof a !== typeof b)
214
+ return false;
215
+ if (typeof a !== "object")
216
+ return false;
217
+ if (Array.isArray(a) || Array.isArray(b)) {
218
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
219
+ return false;
220
+ return a.every((v, i) => deepEqualJson(v, b[i]));
221
+ }
222
+ const ao = a;
223
+ const bo = b;
224
+ const ak = Object.keys(ao).sort();
225
+ const bk = Object.keys(bo).sort();
226
+ if (ak.length !== bk.length)
227
+ return false;
228
+ for (let i = 0; i < ak.length; i++) {
229
+ if (ak[i] !== bk[i])
230
+ return false;
231
+ }
232
+ return ak.every((k) => deepEqualJson(ao[k], bo[k]));
233
+ }
234
+ function stripDirectivePinsFromPackageJson(value) {
235
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
236
+ return value;
237
+ }
238
+ const obj = value;
239
+ const out = {};
240
+ for (const [key, raw] of Object.entries(obj)) {
241
+ if (PACKAGE_JSON_DEP_FIELDS.includes(key) &&
242
+ raw !== null &&
243
+ typeof raw === "object" &&
244
+ !Array.isArray(raw)) {
245
+ const kept = {};
246
+ for (const [depKey, depVal] of Object.entries(raw)) {
247
+ if (!isDirectiveDependencyKey(depKey))
248
+ kept[depKey] = depVal;
249
+ }
250
+ out[key] = kept;
251
+ }
252
+ else {
253
+ out[key] = raw;
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+ /**
259
+ * package.json co-travel is allowed only when the sole differences are
260
+ * `@deftai/directive*` dependency-key pins under the standard dep maps (#3193).
261
+ * Scripts/settings/metadata that merely contain the substring still fail.
262
+ */
263
+ export function isPackageJsonDirectivePinOnlyDiff(baseRaw, headRaw) {
264
+ let base;
265
+ let head;
266
+ try {
267
+ base = JSON.parse(baseRaw);
268
+ head = JSON.parse(headRaw);
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ return deepEqualJson(stripDirectivePinsFromPackageJson(base), stripDirectivePinsFromPackageJson(head));
274
+ }
275
+ function collectDepIdentities(pkg) {
276
+ const out = {};
277
+ for (const field of PACKAGE_JSON_DEP_FIELDS) {
278
+ const block = pkg[field];
279
+ if (block !== null && typeof block === "object" && !Array.isArray(block)) {
280
+ for (const [k, v] of Object.entries(block)) {
281
+ if (typeof v === "string")
282
+ out[k] = v;
283
+ else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
284
+ const version = v.version;
285
+ if (typeof version === "string")
286
+ out[k] = version;
287
+ else
288
+ out[k] = JSON.stringify(v);
289
+ }
290
+ else if (v !== undefined && v !== null) {
291
+ out[k] = String(v);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ return out;
297
+ }
298
+ function onlyDirectiveDirectDepsDiffer(baseDeps, headDeps) {
299
+ const keys = new Set([...Object.keys(baseDeps), ...Object.keys(headDeps)]);
300
+ for (const key of keys) {
301
+ if (isDirectiveDependencyKey(key))
302
+ continue;
303
+ if ((baseDeps[key] ?? null) !== (headDeps[key] ?? null))
304
+ return false;
305
+ }
306
+ return true;
307
+ }
308
+ function npmLockRootDirectDeps(lock) {
309
+ const packages = lock.packages;
310
+ if (packages !== null && typeof packages === "object" && !Array.isArray(packages)) {
311
+ const root = packages[""];
312
+ if (root !== null && typeof root === "object" && !Array.isArray(root)) {
313
+ return collectDepIdentities(root);
314
+ }
315
+ }
316
+ if (lock.dependencies !== null &&
317
+ typeof lock.dependencies === "object" &&
318
+ !Array.isArray(lock.dependencies)) {
319
+ const out = {};
320
+ for (const [k, v] of Object.entries(lock.dependencies)) {
321
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
322
+ const version = v.version;
323
+ out[k] = typeof version === "string" ? version : JSON.stringify(v);
324
+ }
325
+ else if (typeof v === "string") {
326
+ out[k] = v;
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+ return {};
332
+ }
333
+ /** Strip @deftai/directive* keys recursively from an npm v1 dependencies tree. */
334
+ function stripDirectiveFromNpmV1Deps(value) {
335
+ if (value === null || typeof value !== "object" || Array.isArray(value))
336
+ return value;
337
+ const obj = value;
338
+ const out = {};
339
+ for (const [k, v] of Object.entries(obj)) {
340
+ if (isDirectiveDependencyKey(k))
341
+ continue;
342
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
343
+ const entry = v;
344
+ const next = { ...entry };
345
+ if (entry.dependencies !== undefined) {
346
+ next.dependencies = stripDirectiveFromNpmV1Deps(entry.dependencies);
347
+ }
348
+ out[k] = next;
349
+ }
350
+ else {
351
+ out[k] = v;
352
+ }
353
+ }
354
+ return out;
355
+ }
356
+ /**
357
+ * package-lock.json follow-through: non-@deftai/directive* root direct dependency
358
+ * identities (and their `node_modules/<name>` package entries when present) must
359
+ * be unchanged. Directive pin identity + transitive/resolution churn may change.
360
+ */
361
+ export function isPackageLockDirectivePinFollowThrough(baseRaw, headRaw) {
362
+ let base;
363
+ let head;
364
+ try {
365
+ base = JSON.parse(baseRaw);
366
+ head = JSON.parse(headRaw);
367
+ }
368
+ catch {
369
+ return false;
370
+ }
371
+ if (base === null ||
372
+ head === null ||
373
+ typeof base !== "object" ||
374
+ typeof head !== "object" ||
375
+ Array.isArray(base) ||
376
+ Array.isArray(head)) {
377
+ return false;
378
+ }
379
+ const baseObj = base;
380
+ const headObj = head;
381
+ const baseRoot = npmLockRootDirectDeps(baseObj);
382
+ const headRoot = npmLockRootDirectDeps(headObj);
383
+ if (!onlyDirectiveDirectDepsDiffer(baseRoot, headRoot))
384
+ return false;
385
+ const basePkgs = baseObj.packages !== null &&
386
+ typeof baseObj.packages === "object" &&
387
+ !Array.isArray(baseObj.packages)
388
+ ? baseObj.packages
389
+ : {};
390
+ const headPkgs = headObj.packages !== null &&
391
+ typeof headObj.packages === "object" &&
392
+ !Array.isArray(headObj.packages)
393
+ ? headObj.packages
394
+ : {};
395
+ // Freeze every non-@deftai/directive package record (including hoisted
396
+ // transitives and nested product trees). Only packages[""] root dep maps
397
+ // (directive keys only) and node_modules/@deftai/directive* trees may change.
398
+ const allPkgKeys = new Set([...Object.keys(basePkgs), ...Object.keys(headPkgs)]);
399
+ for (const key of allPkgKeys) {
400
+ if (key === "")
401
+ continue;
402
+ if (key.includes("node_modules/@deftai/directive") || key.includes("/@deftai/directive/")) {
403
+ continue;
404
+ }
405
+ if (!deepEqualJson(basePkgs[key], headPkgs[key]))
406
+ return false;
407
+ }
408
+ // Legacy npm lockfileVersion 1: no packages map — freeze the full nested
409
+ // dependencies tree after stripping @deftai/directive* keys (#3193 Greptile).
410
+ if (Object.keys(basePkgs).length === 0 && Object.keys(headPkgs).length === 0) {
411
+ if (!deepEqualJson(stripDirectiveFromNpmV1Deps(baseObj.dependencies), stripDirectiveFromNpmV1Deps(headObj.dependencies))) {
412
+ return false;
413
+ }
414
+ }
415
+ return true;
416
+ }
417
+ /**
418
+ * Minimal pnpm-lock.yaml (v6/v9) root importer (`.`) direct-dep extractor.
419
+ * Avoids a YAML dependency; sufficient for pin follow-through identity checks.
420
+ */
421
+ export function pnpmLockRootDirectDeps(raw) {
422
+ const lines = raw.split(/\r?\n/);
423
+ const out = {};
424
+ let inImporters = false;
425
+ let inRoot = false;
426
+ let inDepBlock = false;
427
+ let currentPkg = null;
428
+ const unquote = (s) => {
429
+ const t = s.trim();
430
+ if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
431
+ return t.slice(1, -1);
432
+ }
433
+ return t;
434
+ };
435
+ for (const line of lines) {
436
+ if (/^importers:\s*$/.test(line)) {
437
+ inImporters = true;
438
+ inRoot = false;
439
+ inDepBlock = false;
440
+ currentPkg = null;
441
+ continue;
442
+ }
443
+ if (!inImporters)
444
+ continue;
445
+ // Left the importers section (top-level key at column 0).
446
+ if (/^[^\s#]/.test(line) && !line.startsWith("importers")) {
447
+ break;
448
+ }
449
+ // Root importer `.` (quoted or bare).
450
+ if (/^ {2}(?:\.|'\.'|"\."):\s*$/.test(line)) {
451
+ inRoot = true;
452
+ inDepBlock = false;
453
+ currentPkg = null;
454
+ continue;
455
+ }
456
+ // Sibling importer under importers (2-space indent, not a dep field).
457
+ if (inRoot && /^ {2}\S/.test(line) && !/^ {2}\./.test(line)) {
458
+ inRoot = false;
459
+ inDepBlock = false;
460
+ currentPkg = null;
461
+ continue;
462
+ }
463
+ if (!inRoot)
464
+ continue;
465
+ if (/^ {4}(?:dependencies|devDependencies|optionalDependencies|peerDependencies):\s*$/.test(line)) {
466
+ inDepBlock = true;
467
+ currentPkg = null;
468
+ continue;
469
+ }
470
+ // Non-dep field under root importer ends a dep block.
471
+ if (inDepBlock && /^ {4}\S/.test(line)) {
472
+ inDepBlock = false;
473
+ currentPkg = null;
474
+ continue;
475
+ }
476
+ if (!inDepBlock)
477
+ continue;
478
+ const pkgMatch = line.match(/^ {6}(.+?):\s*$/);
479
+ if (pkgMatch) {
480
+ currentPkg = unquote(pkgMatch[1] ?? "");
481
+ continue;
482
+ }
483
+ if (currentPkg) {
484
+ const verMatch = line.match(/^ {8}version:\s*(.+?)\s*$/);
485
+ if (verMatch) {
486
+ out[currentPkg] = unquote(verMatch[1] ?? "");
487
+ currentPkg = null;
488
+ }
489
+ }
490
+ }
491
+ return out;
492
+ }
493
+ /**
494
+ * Extract pnpm `packages:` section records keyed by the package name (not
495
+ * name@version). Used to freeze product package resolution blocks.
496
+ */
497
+ export function pnpmPackagesByName(raw) {
498
+ const map = new Map();
499
+ const lines = raw.split(/\r?\n/);
500
+ let inPackages = false;
501
+ let currentName = null;
502
+ let buf = [];
503
+ const flush = () => {
504
+ if (currentName === null)
505
+ return;
506
+ const prev = map.get(currentName) ?? [];
507
+ prev.push(buf.join("\n"));
508
+ map.set(currentName, prev);
509
+ currentName = null;
510
+ buf = [];
511
+ };
512
+ const unquote = (s) => {
513
+ const t = s.trim();
514
+ if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
515
+ return t.slice(1, -1);
516
+ }
517
+ return t;
518
+ };
519
+ for (const line of lines) {
520
+ if (/^packages:\s*$/.test(line)) {
521
+ flush();
522
+ inPackages = true;
523
+ continue;
524
+ }
525
+ if (!inPackages)
526
+ continue;
527
+ if (/^[^\s#]/.test(line) && !line.startsWith("packages")) {
528
+ flush();
529
+ break;
530
+ }
531
+ // Package key at 2-space indent: ` lodash@4.17.21:` or ` '@scope/pkg@1.0.0':`
532
+ const keyMatch = line.match(/^ {2}(.+?):\s*$/);
533
+ if (keyMatch) {
534
+ flush();
535
+ const key = unquote(keyMatch[1] ?? "");
536
+ // name@version or @scope/name@version — strip version suffix after last @
537
+ // that is not the scope marker.
538
+ const at = key.startsWith("@") ? key.indexOf("@", 1) : key.indexOf("@");
539
+ currentName = at > 0 ? key.slice(0, at) : key;
540
+ buf = [line];
541
+ continue;
542
+ }
543
+ if (currentName !== null)
544
+ buf.push(line);
545
+ }
546
+ flush();
547
+ // Join multi-version records for stable compare.
548
+ const out = new Map();
549
+ for (const [name, blocks] of map) {
550
+ out.set(name, blocks.slice().sort().join("\n---\n"));
551
+ }
552
+ return out;
553
+ }
554
+ /**
555
+ * pnpm-lock.yaml follow-through: root importer (`.`) non-directive direct dep
556
+ * identities must be unchanged, and every non-@deftai/directive packages-section
557
+ * record (including transitive product packages) must be byte-stable (#3193).
558
+ */
559
+ /**
560
+ * Extract a top-level pnpm section (`packages:` or `snapshots:`) keyed by package name.
561
+ */
562
+ function pnpmNamedSectionByName(raw, section) {
563
+ const map = new Map();
564
+ const lines = raw.split(/\r?\n/);
565
+ let inSection = false;
566
+ let currentName = null;
567
+ let buf = [];
568
+ const sectionRe = new RegExp(`^${section}:\\s*$`);
569
+ const flush = () => {
570
+ if (currentName === null)
571
+ return;
572
+ const prev = map.get(currentName) ?? [];
573
+ prev.push(buf.join("\n"));
574
+ map.set(currentName, prev);
575
+ currentName = null;
576
+ buf = [];
577
+ };
578
+ const unquote = (s) => {
579
+ const t = s.trim();
580
+ if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
581
+ return t.slice(1, -1);
582
+ }
583
+ return t;
584
+ };
585
+ for (const line of lines) {
586
+ if (sectionRe.test(line)) {
587
+ flush();
588
+ inSection = true;
589
+ continue;
590
+ }
591
+ if (!inSection)
592
+ continue;
593
+ if (/^[^\s#]/.test(line) && !line.startsWith(section)) {
594
+ flush();
595
+ break;
596
+ }
597
+ const keyMatch = line.match(/^ {2}(.+?):\s*$/);
598
+ if (keyMatch) {
599
+ flush();
600
+ const key = unquote(keyMatch[1] ?? "");
601
+ const at = key.startsWith("@") ? key.indexOf("@", 1) : key.indexOf("@");
602
+ currentName = at > 0 ? key.slice(0, at) : key;
603
+ buf = [line];
604
+ continue;
605
+ }
606
+ if (currentName !== null)
607
+ buf.push(line);
608
+ }
609
+ flush();
610
+ const out = new Map();
611
+ for (const [name, blocks] of map) {
612
+ out.set(name, blocks.slice().sort().join("\n---\n"));
613
+ }
614
+ return out;
615
+ }
616
+ export function isPnpmLockDirectivePinFollowThrough(baseRaw, headRaw) {
617
+ const baseRoot = pnpmLockRootDirectDeps(baseRaw);
618
+ const headRoot = pnpmLockRootDirectDeps(headRaw);
619
+ if (!onlyDirectiveDirectDepsDiffer(baseRoot, headRoot))
620
+ return false;
621
+ for (const section of ["packages", "snapshots"]) {
622
+ const baseSec = pnpmNamedSectionByName(baseRaw, section);
623
+ const headSec = pnpmNamedSectionByName(headRaw, section);
624
+ const names = new Set([...baseSec.keys(), ...headSec.keys()]);
625
+ for (const name of names) {
626
+ if (isDirectiveDependencyKey(name))
627
+ continue;
628
+ if ((baseSec.get(name) ?? null) !== (headSec.get(name) ?? null))
629
+ return false;
630
+ }
631
+ }
632
+ return true;
633
+ }
634
+ /**
635
+ * yarn.lock (v1) package identity blocks: every non-@deftai/directive* package
636
+ * must keep identical block text (or fail if added/removed). Directive package
637
+ * blocks may change freely as pin follow-through (#3193).
638
+ */
639
+ export function isYarnLockDirectivePinFollowThrough(baseRaw, headRaw) {
640
+ const baseBlocks = yarnLockPackageBlocks(baseRaw);
641
+ const headBlocks = yarnLockPackageBlocks(headRaw);
642
+ const names = new Set([...baseBlocks.keys(), ...headBlocks.keys()]);
643
+ for (const name of names) {
644
+ if (isDirectiveDependencyKey(name))
645
+ continue;
646
+ if ((baseBlocks.get(name) ?? null) !== (headBlocks.get(name) ?? null))
647
+ return false;
648
+ }
649
+ return true;
650
+ }
651
+ function yarnLockPackageBlocks(raw) {
652
+ const map = new Map();
653
+ const lines = raw.split(/\r?\n/);
654
+ let currentNames = [];
655
+ let buf = [];
656
+ const flush = () => {
657
+ if (currentNames.length === 0)
658
+ return;
659
+ const body = buf.join("\n");
660
+ for (const name of currentNames) {
661
+ map.set(name, body);
662
+ }
663
+ currentNames = [];
664
+ buf = [];
665
+ };
666
+ for (const line of lines) {
667
+ if (line === "" || line.startsWith("#")) {
668
+ if (currentNames.length > 0 && line === "") {
669
+ flush();
670
+ }
671
+ continue;
672
+ }
673
+ // Package header: `"lodash@^4.17.21":` or `lodash@^4.17.21:`
674
+ if (!/^\s/.test(line) && line.endsWith(":")) {
675
+ flush();
676
+ const header = line.slice(0, -1);
677
+ currentNames = header.split(",").map((part) => {
678
+ let p = part.trim();
679
+ if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
680
+ p = p.slice(1, -1);
681
+ }
682
+ // name@version or @scope/name@version
683
+ const at = p.startsWith("@") ? p.indexOf("@", 1) : p.indexOf("@");
684
+ return at > 0 ? p.slice(0, at) : p;
685
+ });
686
+ buf = [line];
687
+ continue;
688
+ }
689
+ if (currentNames.length > 0)
690
+ buf.push(line);
691
+ }
692
+ flush();
693
+ return map;
694
+ }
695
+ /**
696
+ * Content-aware check for a single upgrade pin path (#3193).
697
+ * Returns true when the path may co-travel with `.deft/core/**`.
698
+ */
699
+ export function isUpgradePinPathContentAllowed(path, baseRaw, headRaw) {
700
+ const normalized = path.replace(/\\/g, "/");
701
+ switch (normalized) {
702
+ case "package.json":
703
+ return isPackageJsonDirectivePinOnlyDiff(baseRaw, headRaw);
704
+ case "package-lock.json":
705
+ return isPackageLockDirectivePinFollowThrough(baseRaw, headRaw);
706
+ case "pnpm-lock.yaml":
707
+ return isPnpmLockDirectivePinFollowThrough(baseRaw, headRaw);
708
+ case "yarn.lock":
709
+ return isYarnLockDirectivePinFollowThrough(baseRaw, headRaw);
710
+ default:
711
+ return false;
712
+ }
713
+ }
186
714
  /**
187
715
  * TS twin of Go `classifyChangedPaths` / deposited shell guard (#1430).
188
716
  * Core = `.deft/core/**`; installer-managed = allowlist; app = everything else.
189
717
  * Guard fails iff both core and app are non-empty.
718
+ *
719
+ * Path-only: does not inspect package/lock contents. Prefer
720
+ * {@link classifyMixedCoreAndAppContentAware} when base/head blobs are available
721
+ * (deposited guard + unit tests for #3193).
190
722
  */
191
723
  export function classifyMixedCoreAndApp(changedPaths, matchers = installerManagedMatchers()) {
192
724
  const core = [];
@@ -213,6 +745,47 @@ export function classifyMixedCoreAndApp(changedPaths, matchers = installerManage
213
745
  wouldFail: core.length > 0 && app.length > 0,
214
746
  };
215
747
  }
748
+ /**
749
+ * Content-aware upgrade co-travel classifier (#3193).
750
+ *
751
+ * Starts from path classification (#3127 allowlist), then when `.deft/core/**`
752
+ * is present reclassifies package.json / lockfile paths as **app** unless their
753
+ * base→head content is the Directive pin unit (or lock follow-through).
754
+ * `.deft/GENERATION.json` remains path-allowlisted with no content constraint.
755
+ *
756
+ * Missing content for a pin path co-travelling with core fails closed (treated
757
+ * as app) so partial fixtures cannot silently re-open the path-only hole.
758
+ */
759
+ export function classifyMixedCoreAndAppContentAware(changedPaths, fileContents, matchers = installerManagedMatchers()) {
760
+ const base = classifyMixedCoreAndApp(changedPaths, matchers);
761
+ if (base.core.length === 0) {
762
+ return { ...base, pinContentRejected: [] };
763
+ }
764
+ const pinContentRejected = [];
765
+ const keptManaged = [];
766
+ const app = [...base.app];
767
+ for (const path of base.installerManaged) {
768
+ if (!UPGRADE_PIN_CONTENT_PATHS.includes(path)) {
769
+ keptManaged.push(path);
770
+ continue;
771
+ }
772
+ const pair = fileContents[path];
773
+ if (!pair || !isUpgradePinPathContentAllowed(path, pair.base, pair.head)) {
774
+ pinContentRejected.push(path);
775
+ app.push(path);
776
+ }
777
+ else {
778
+ keptManaged.push(path);
779
+ }
780
+ }
781
+ return {
782
+ core: base.core,
783
+ installerManaged: keptManaged,
784
+ app,
785
+ wouldFail: app.length > 0,
786
+ pinContentRejected,
787
+ };
788
+ }
216
789
  export function frameworkStagePaths(projectDir, deftDir, options = {}) {
217
790
  const paths = [];
218
791
  const seen = new Set();
@@ -486,11 +1059,12 @@ export function printCommitGuidance(io, paths, staged) {
486
1059
  if (paths.length === 0)
487
1060
  return;
488
1061
  const addCmd = `git add ${paths.join(" ")}`;
489
- io.printf("\nCommit hygiene (#1453, #1671, #3127): keep the framework upgrade in its OWN branch/PR.\n");
1062
+ io.printf("\nCommit hygiene (#1453, #1671, #3127, #3193): keep the framework upgrade in its OWN branch/PR.\n");
490
1063
  io.printf("Do NOT use `git add -A` -- mixing the payload with product/app files trips the\n");
491
1064
  io.printf("deft-core-guard CI check.\n");
492
1065
  io.printf("One upgrade PR MAY co-travel: .deft/core/** + installer-managed deposits + package.json\n");
493
- io.printf("pin/lock + .deft/GENERATION.json. True app/product paths still require a separate PR.\n");
1066
+ io.printf("pin/lock (Directive pin-only + lock follow-through, #3193) + .deft/GENERATION.json.\n");
1067
+ io.printf("True app/product paths still require a separate PR.\n");
494
1068
  if (staged) {
495
1069
  io.printf("The installer already staged ONLY these framework + installer-managed paths:\n");
496
1070
  io.printf(` ${addCmd}\n`);