forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8

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.
@@ -11,6 +11,7 @@ const { hashProtectedContent, normalizeRepoPath } = require('./protected-state-s
11
11
  const {
12
12
  BUN_WORKFLOW_SPECS,
13
13
  deriveBunWorkflowUpdate,
14
+ readCompleteBunPinBatch,
14
15
  readIndexedPackageManifest,
15
16
  readIndexedWorkflow,
16
17
  readPinnedBunVersion,
@@ -28,6 +29,7 @@ const PROTECTED_STATE_AUTHORIZATION_CONSUMED = 'protected_state.authorization.co
28
29
  const PROTECTED_STATE_AUTHORIZATION_VERSION = 1;
29
30
  const PROTECTED_STATE_AUTHORIZATION_ORIGIN = 'cli';
30
31
  const NPM_WORKFLOW_SOURCE_COMMAND = 'forge release generate-npm-workflow';
32
+ const TEST_WORKFLOW_SOURCE_COMMAND = 'forge release generate-test-workflow';
31
33
  const BUN_WORKFLOW_SOURCE_COMMAND = 'forge release update-bun-pins';
32
34
  const BUN_WORKFLOW_PATHS = new Set(BUN_WORKFLOW_SPECS.map(spec => spec.path));
33
35
  const SKILL_MIRROR_SOURCE_COMMAND = 'scripts/sync-agent-skills.js';
@@ -52,6 +54,13 @@ function owningWriter(pathname, surface) {
52
54
  completionOperation: 'generate_npm_workflow_completed',
53
55
  };
54
56
  }
57
+ if (surface === 'workflows' && pathname === '.github/workflows/test.yml') {
58
+ return {
59
+ sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
60
+ issueOperation: 'generate_test_workflow',
61
+ completionOperation: 'generate_test_workflow_completed',
62
+ };
63
+ }
55
64
  if (surface === 'generated_harness' && /^\.agents\/skills\/[^/]+\/.+/.test(pathname)) {
56
65
  return {
57
66
  sourceCommand: SKILL_MIRROR_SOURCE_COMMAND,
@@ -165,7 +174,8 @@ function evaluateAuthorization(request, rows = []) {
165
174
  const latest = active[0];
166
175
  const writer = owningWriter(latest.path, latest.surface);
167
176
 
168
- const bunPinWriter = [BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND].includes(writer?.sourceCommand);
177
+ const bunPinWriter = [BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND, TEST_WORKFLOW_SOURCE_COMMAND]
178
+ .includes(writer?.sourceCommand);
169
179
  const structurallyValid =
170
180
  writer !== null &&
171
181
  latest.version === PROTECTED_STATE_AUTHORIZATION_VERSION &&
@@ -444,6 +454,57 @@ async function issueBunWorkflowAuthorization(projectRoot, params = {}, options =
444
454
  }, options);
445
455
  }
446
456
 
457
+ function resolveCurrentTestWorkflowTarget(projectRoot, params, requireWritten = false) {
458
+ let update;
459
+ try {
460
+ update = require('./test-workflow').resolveTestWorkflowUpdate(projectRoot, params.sourceHead);
461
+ if (update.version !== params.targetBunVersion) {
462
+ return { success: false, error: 'Protected test workflow target does not match the staged package.json Bun pin.' };
463
+ }
464
+ if (requireWritten) {
465
+ const fullPath = path.join(projectRoot, update.path);
466
+ const stat = fs.lstatSync(fullPath);
467
+ if (!stat.isFile() || stat.isSymbolicLink() || !fs.readFileSync(fullPath).equals(update.content)) {
468
+ return { success: false, error: 'Protected test workflow completion requires the exact generated workflow bytes.' };
469
+ }
470
+ }
471
+ } catch (error) {
472
+ return { success: false, error: error.message };
473
+ }
474
+ return { success: true, update };
475
+ }
476
+
477
+ /**
478
+ * Authorizes the canonical test workflow derived from the current staged package and template.
479
+ * The target and exact generated bytes are resolved again before the capability is inserted.
480
+ */
481
+ async function issueTestWorkflowAuthorization(projectRoot, params = {}, options = {}) {
482
+ if (!isValidGitObjectId(params.sourceHead)) {
483
+ return { success: false, error: 'Protected test workflow authorization requires a full 40- or 64-character lowercase source HEAD.' };
484
+ }
485
+ const target = resolveCurrentTestWorkflowTarget(projectRoot, params);
486
+ if (!target.success) return target;
487
+ const expectedHash = hashProtectedContent(target.update.content);
488
+ return issueProtectedStateAuthorization(projectRoot, {
489
+ actor: params.actor,
490
+ surface: 'workflows',
491
+ path: target.update.path,
492
+ content: target.update.content,
493
+ operation: 'generate_test_workflow',
494
+ viaForgeApi: true,
495
+ sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
496
+ sourceHead: params.sourceHead,
497
+ targetBunVersion: params.targetBunVersion,
498
+ verifyBeforeInsert: () => {
499
+ const fresh = resolveCurrentTestWorkflowTarget(projectRoot, params);
500
+ if (!fresh.success) return fresh;
501
+ return hashProtectedContent(fresh.update.content) === expectedHash
502
+ ? { success: true }
503
+ : { success: false, error: 'Protected test workflow target changed during authorization.' };
504
+ },
505
+ }, options);
506
+ }
507
+
447
508
  function findExactAuthorization(rows, params, expected) {
448
509
  const issued = (rows || [])
449
510
  .filter(row => row?.event_type === PROTECTED_STATE_AUTHORIZATION_ISSUED)
@@ -645,6 +706,74 @@ async function completeBunWorkflowAuthorization(projectRoot, params = {}, option
645
706
  }
646
707
  }
647
708
 
709
+ /**
710
+ * Completes only an active capability that matches the canonical target and exact written bytes.
711
+ * The staged target and generated file are rechecked before the completion event is inserted.
712
+ */
713
+ async function completeTestWorkflowAuthorization(projectRoot, params = {}, options = {}) {
714
+ if (!isValidGitObjectId(params.sourceHead) || !params.capabilityId) {
715
+ return { success: false, error: 'Protected test workflow completion requires a capability id and full 40- or 64-character source HEAD.' };
716
+ }
717
+ const target = resolveCurrentTestWorkflowTarget(projectRoot, params, true);
718
+ if (!target.success) return target;
719
+ const actor = params.actor;
720
+ const normalizedPath = normalizeRepoPath(target.update.path);
721
+ const contentHash = hashProtectedContent(target.update.content);
722
+ const worktreeScope = options.worktreeScope || resolveWorktreeScope(projectRoot, options.deps);
723
+ const entityId = authorizationEntityId(worktreeScope, normalizedPath);
724
+ const kernel = await resolveOwnedKernel(projectRoot, options.deps);
725
+ try {
726
+ const rows = await kernel.driver.listKernelEvents(PROTECTED_STATE_ENTITY_TYPE, entityId, {}, kernel.config);
727
+ const { exactIssued, consumed } = findExactAuthorization(rows, params, {
728
+ actor,
729
+ entityId,
730
+ path: normalizedPath,
731
+ surface: 'workflows',
732
+ contentHash,
733
+ worktreeScope,
734
+ operation: 'generate_test_workflow',
735
+ writeIntent: 'update',
736
+ sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
737
+ targetBunVersion: params.targetBunVersion,
738
+ });
739
+ if (!exactIssued || consumed) {
740
+ return { success: false, error: 'Protected test workflow completion does not match one active exact authorization.' };
741
+ }
742
+ const fresh = resolveCurrentTestWorkflowTarget(projectRoot, params, true);
743
+ if (!fresh.success || hashProtectedContent(fresh.update.content) !== contentHash) {
744
+ return fresh.success ? { success: false, error: 'Protected test workflow target changed during completion.' } : fresh;
745
+ }
746
+ const inserted = await kernel.driver.insertKernelEvent({
747
+ entity_type: PROTECTED_STATE_ENTITY_TYPE,
748
+ entity_id: entityId,
749
+ event_type: PROTECTED_STATE_WRITE_COMPLETED,
750
+ idempotency_key: `${PROTECTED_STATE_WRITE_COMPLETED}:${params.capabilityId}`,
751
+ expected_revision: 0,
752
+ actor,
753
+ origin: PROTECTED_STATE_AUTHORIZATION_ORIGIN,
754
+ payload: {
755
+ version: PROTECTED_STATE_AUTHORIZATION_VERSION,
756
+ capabilityId: params.capabilityId,
757
+ actor,
758
+ path: normalizedPath,
759
+ surface: 'workflows',
760
+ contentHash,
761
+ sourceHead: params.sourceHead,
762
+ worktreeScope,
763
+ writeIntent: 'update',
764
+ operation: 'generate_test_workflow_completed',
765
+ viaForgeApi: true,
766
+ sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
767
+ targetBunVersion: params.targetBunVersion,
768
+ },
769
+ created_at: options.now || new Date().toISOString(),
770
+ }, {}, kernel.config);
771
+ return { success: true, capabilityId: params.capabilityId, event: parseAuthorizationEvent(inserted) };
772
+ } finally {
773
+ closeIfOwned(kernel);
774
+ }
775
+ }
776
+
648
777
  async function cancelBunWorkflowBatchAuthorizations(projectRoot, requests = [], options = {}) {
649
778
  if (requests.length === 0) return { success: true, results: [] };
650
779
  const worktreeScope = options.worktreeScope || resolveWorktreeScope(projectRoot, options.deps);
@@ -655,7 +784,7 @@ async function cancelBunWorkflowBatchAuthorizations(projectRoot, requests = [],
655
784
  const normalizedPath = normalizeRepoPath(request.path);
656
785
  const writer = owningWriter(normalizedPath, 'workflows');
657
786
  if (!request.actor || !isValidGitObjectId(request.sourceHead) || !request.capabilityId ||
658
- !writer || ![BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND].includes(writer.sourceCommand)) {
787
+ !writer || ![BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND, TEST_WORKFLOW_SOURCE_COMMAND].includes(writer.sourceCommand)) {
659
788
  results.push({ success: false, path: normalizedPath, capabilityId: request.capabilityId, error: 'Invalid Bun workflow batch cancellation request.' });
660
789
  continue;
661
790
  }
@@ -707,7 +836,7 @@ async function cancelBunWorkflowBatchAuthorizations(projectRoot, requests = [],
707
836
  writeIntent: 'update',
708
837
  operation: 'batch_rollback',
709
838
  viaForgeApi: true,
710
- sourceCommand: BUN_WORKFLOW_SOURCE_COMMAND,
839
+ sourceCommand: writer.sourceCommand,
711
840
  },
712
841
  created_at: options.now || new Date().toISOString(),
713
842
  }, {}, kernel.config);
@@ -954,41 +1083,111 @@ function validateBunDecisionTargets(projectRoot, decisions) {
954
1083
  }
955
1084
  }
956
1085
 
1086
+ /**
1087
+ * Validates the complete staged Bun workflow projection, including canonical test workflow output
1088
+ * when the pin is unchanged. Repositories with neither source nor staged template retain the legacy
1089
+ * path; bootstrap must add the template and output together, and established deletion is incomplete.
1090
+ */
957
1091
  function validateCompleteBunPinBatch(projectRoot, options = {}) {
958
1092
  if (options.sourceHead === null) return { success: true };
959
1093
  let sourceHead;
960
1094
  let sourcePackage;
1095
+ let snapshot;
961
1096
  try {
962
- sourceHead = options.sourceHead || resolveCurrentHead(projectRoot);
963
- sourcePackage = Buffer.from((options.readSourcePackageManifest || readSourcePackageManifest)(projectRoot, sourceHead));
1097
+ sourceHead = options.sourceHead || resolveCurrentHead(projectRoot, options.execGit);
1098
+ sourcePackage = Buffer.from(
1099
+ (options.readSourcePackageManifest || readSourcePackageManifest)(projectRoot, sourceHead),
1100
+ );
1101
+ if (JSON.parse(sourcePackage.toString('utf8')).name !== 'forge-workflow') return { success: true };
964
1102
  } catch (error) {
965
1103
  if (error.message.includes('package.json is not a regular file at')) return { success: true };
966
1104
  return { success: false, error: `Could not verify the complete staged Bun pin update: ${error.message}` };
967
1105
  }
968
1106
  try {
969
- if (JSON.parse(sourcePackage.toString('utf8')).name !== 'forge-workflow') return { success: true };
970
1107
  const sourceVersion = readPinnedBunVersion(sourcePackage);
971
- const indexedPackage = Buffer.from((options.readIndexedPackageManifest || readIndexedPackageManifest)(projectRoot));
1108
+ const indexedPackage = Buffer.from(
1109
+ (options.readIndexedPackageManifest || readIndexedPackageManifest)(projectRoot),
1110
+ );
972
1111
  const targetVersion = readPinnedBunVersion(indexedPackage);
973
- if (targetVersion === sourceVersion) return { success: true };
974
- if (resolveBunVersionTarget(projectRoot, options).version !== targetVersion) {
1112
+ const testWorkflow = require('./test-workflow');
1113
+ if (targetVersion === sourceVersion) {
1114
+ const projection = (options.readTestWorkflowProjection || testWorkflow.readTestWorkflowProjection)(
1115
+ projectRoot,
1116
+ sourceHead,
1117
+ options.execGit,
1118
+ );
1119
+ if (!Buffer.from(projection.sourcePackage).equals(sourcePackage)
1120
+ || !Buffer.from(projection.indexedPackage).equals(indexedPackage)) {
1121
+ throw new Error('package.json changed while reading the staged test workflow projection');
1122
+ }
1123
+ if (projection.sourceTemplate === null && projection.indexedTemplate === null) return { success: true };
1124
+ if (projection.indexedTemplate === null) {
1125
+ return { success: false, error: `${testWorkflow.TEST_WORKFLOW_TEMPLATE_PATH} is not one regular file in the Git index.` };
1126
+ }
1127
+ if (projection.indexedWorkflow === null) {
1128
+ return { success: false, error: `${testWorkflow.TEST_WORKFLOW_PATH} is not one regular file in the Git index.` };
1129
+ }
1130
+ const expectedTest = testWorkflow.renderTestWorkflow(projection.indexedTemplate, targetVersion);
1131
+ if (!Buffer.from(projection.indexedWorkflow).equals(expectedTest)) {
1132
+ return { success: false, error: `${testWorkflow.TEST_WORKFLOW_PATH} does not match the staged canonical template for Bun ${targetVersion}.` };
1133
+ }
1134
+ return { success: true };
1135
+ }
1136
+ const npmWorkflow = require('./npm-publish-workflow');
1137
+ const hasLegacyReaders = options.readSourcePackageManifest || options.readIndexedPackageManifest
1138
+ || options.readSourceWorkflow || options.readIndexedWorkflow || options.readIndexedNpmPublishWorkflow;
1139
+ if (options.readCompleteBunPinBatch || !hasLegacyReaders) {
1140
+ snapshot = (options.readCompleteBunPinBatch || readCompleteBunPinBatch)(
1141
+ projectRoot,
1142
+ sourceHead,
1143
+ [npmWorkflow.NPM_PUBLISH_WORKFLOW_PATH, testWorkflow.TEST_WORKFLOW_PATH],
1144
+ options.execGit,
1145
+ );
1146
+ if (!Buffer.from(snapshot.source.get('package.json')).equals(sourcePackage)
1147
+ || !Buffer.from(snapshot.indexed.get('package.json')).equals(indexedPackage)) {
1148
+ throw new Error('package.json changed while reading the complete Bun pin snapshot');
1149
+ }
1150
+ }
1151
+ if (resolveBunVersionTarget(projectRoot, {
1152
+ ...options,
1153
+ readIndexedPackageManifest: () => indexedPackage,
1154
+ }).version !== targetVersion) {
975
1155
  return { success: false, error: 'The working package.json Bun pin differs from the staged pin.' };
976
1156
  }
977
1157
 
978
1158
  for (const spec of BUN_WORKFLOW_SPECS) {
979
- const baseline = (options.readSourceWorkflow || readSourceWorkflow)(projectRoot, sourceHead, spec.path);
1159
+ const baseline = snapshot
1160
+ ? snapshot.source.get(spec.path)
1161
+ : (options.readSourceWorkflow || readSourceWorkflow)(projectRoot, sourceHead, spec.path);
980
1162
  const expected = Buffer.from(renderBunWorkflowPin(spec.path, baseline, targetVersion));
981
- const indexed = Buffer.from((options.readIndexedWorkflow || readIndexedWorkflow)(projectRoot, spec.path));
1163
+ const indexed = Buffer.from(snapshot
1164
+ ? snapshot.indexed.get(spec.path)
1165
+ : (options.readIndexedWorkflow || readIndexedWorkflow)(projectRoot, spec.path));
982
1166
  if (!indexed.equals(expected)) {
983
1167
  return { success: false, error: `A staged Bun pin change requires the complete generated workflow set; ${spec.path} does not match Bun ${targetVersion}.` };
984
1168
  }
985
1169
  }
986
1170
 
987
- const npmWorkflow = require('./npm-publish-workflow');
988
- const indexedNpm = Buffer.from((options.readIndexedNpmPublishWorkflow || npmWorkflow.readIndexedNpmPublishWorkflow)(projectRoot));
1171
+ const indexedNpm = Buffer.from(snapshot
1172
+ ? snapshot.indexed.get(npmWorkflow.NPM_PUBLISH_WORKFLOW_PATH)
1173
+ : (options.readIndexedNpmPublishWorkflow || npmWorkflow.readIndexedNpmPublishWorkflow)(projectRoot));
989
1174
  if (!indexedNpm.equals(Buffer.from(npmWorkflow.renderNpmPublishWorkflow(targetVersion)))) {
990
1175
  return { success: false, error: `A staged Bun pin change requires the complete generated workflow set; ${npmWorkflow.NPM_PUBLISH_WORKFLOW_PATH} does not match Bun ${targetVersion}.` };
991
1176
  }
1177
+ const testUpdate = (options.resolveTestWorkflowUpdate || testWorkflow.resolveTestWorkflowUpdate)(
1178
+ projectRoot,
1179
+ sourceHead,
1180
+ options,
1181
+ );
1182
+ const indexedTest = Buffer.from(snapshot
1183
+ ? snapshot.indexed.get(testWorkflow.TEST_WORKFLOW_PATH)
1184
+ : testUpdate.indexedWorkflow);
1185
+ if (!indexedTest.equals(testUpdate.indexedWorkflow)) {
1186
+ throw new Error(`${testWorkflow.TEST_WORKFLOW_PATH} changed while reading the complete Bun pin snapshot`);
1187
+ }
1188
+ if (!indexedTest.equals(testUpdate.content)) {
1189
+ return { success: false, error: `A staged Bun pin change requires the complete generated workflow set; ${testWorkflow.TEST_WORKFLOW_PATH} does not match Bun ${targetVersion}.` };
1190
+ }
992
1191
  return { success: true, sourceVersion, targetVersion };
993
1192
  } catch (error) {
994
1193
  return { success: false, error: `Could not verify the complete staged Bun pin update: ${error.message}` };
@@ -1091,6 +1290,8 @@ module.exports = {
1091
1290
  completeNpmPublishWorkflowAuthorization,
1092
1291
  issueBunWorkflowAuthorization,
1093
1292
  completeBunWorkflowAuthorization,
1293
+ issueTestWorkflowAuthorization,
1294
+ completeTestWorkflowAuthorization,
1094
1295
  cancelBunWorkflowBatchAuthorizations,
1095
1296
  issueGeneratedHarnessClaudeAuthorization,
1096
1297
  completeGeneratedHarnessClaudeAuthorization,
@@ -318,20 +318,19 @@ function collectAgentInstructionSurfaces(projectRoot) {
318
318
  };
319
319
  }
320
320
 
321
- function getRootDocFiles(projectRoot) {
322
- return new Set(collectAgentInstructionSurfaces(projectRoot).rootDocFiles);
321
+ function getRootDocFiles(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
322
+ return new Set(surfaces.rootDocFiles);
323
323
  }
324
324
 
325
- function getInstructionPrefixes(projectRoot) {
326
- return collectAgentInstructionSurfaces(projectRoot).instructionRoots.map(root => `${root}/`);
325
+ function getInstructionPrefixes(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
326
+ return surfaces.instructionRoots.map(root => `${root}/`);
327
327
  }
328
328
 
329
- function getSkillPrefixes(projectRoot) {
330
- return collectAgentInstructionSurfaces(projectRoot).skillRoots.map(root => `${root}/`);
329
+ function getSkillPrefixes(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
330
+ return surfaces.skillRoots.map(root => `${root}/`);
331
331
  }
332
332
 
333
- function getDefaultScanRoots(projectRoot) {
334
- const surfaces = collectAgentInstructionSurfaces(projectRoot);
333
+ function getDefaultScanRoots(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
335
334
  return [
336
335
  ...new Set([
337
336
  ...surfaces.rootDocFiles,
@@ -342,18 +341,18 @@ function getDefaultScanRoots(projectRoot) {
342
341
  ];
343
342
  }
344
343
 
345
- function isTextFile(relativePath, projectRoot) {
344
+ function isTextFile(relativePath, projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
346
345
  const basename = path.basename(relativePath);
347
346
  if (basename.endsWith('.test.js')) {
348
347
  return false;
349
348
  }
350
- if (getRootDocFiles(projectRoot).has(relativePath) || basename === 'lefthook.yml') {
349
+ if (getRootDocFiles(projectRoot, surfaces).has(relativePath) || basename === 'lefthook.yml') {
351
350
  return true;
352
351
  }
353
352
  return TEXT_EXTENSIONS.has(path.extname(relativePath));
354
353
  }
355
354
 
356
- function walkFiles(projectRoot, relativeRoot) {
355
+ function walkFiles(projectRoot, relativeRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
357
356
  const rootPath = absolutePath(projectRoot, relativeRoot);
358
357
  if (!fs.existsSync(rootPath)) {
359
358
  return [];
@@ -362,7 +361,7 @@ function walkFiles(projectRoot, relativeRoot) {
362
361
  const stat = fs.statSync(rootPath);
363
362
  if (stat.isFile()) {
364
363
  const normalized = toRepoPath(relativeRoot);
365
- return isTextFile(normalized, projectRoot) && !shouldSkipRelativePath(normalized) ? [normalized] : [];
364
+ return isTextFile(normalized, projectRoot, surfaces) && !shouldSkipRelativePath(normalized) ? [normalized] : [];
366
365
  }
367
366
 
368
367
  const files = [];
@@ -380,8 +379,8 @@ function walkFiles(projectRoot, relativeRoot) {
380
379
  }
381
380
 
382
381
  if (entry.isDirectory()) {
383
- files.push(...walkFiles(projectRoot, relativePath));
384
- } else if (entry.isFile() && isTextFile(relativePath, projectRoot)) {
382
+ files.push(...walkFiles(projectRoot, relativePath, surfaces));
383
+ } else if (entry.isFile() && isTextFile(relativePath, projectRoot, surfaces)) {
385
384
  files.push(relativePath);
386
385
  }
387
386
  }
@@ -389,7 +388,7 @@ function walkFiles(projectRoot, relativeRoot) {
389
388
  return files;
390
389
  }
391
390
 
392
- function readSyncManifestScanRoots(projectRoot) {
391
+ function readSyncManifestScanRoots(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
393
392
  const manifestPath = absolutePath(projectRoot, '.forge/sync-manifest.json');
394
393
  if (!fs.existsSync(manifestPath)) {
395
394
  return [];
@@ -406,7 +405,7 @@ function readSyncManifestScanRoots(projectRoot) {
406
405
  .filter(file => (
407
406
  file.length > 0 &&
408
407
  isSafeRepoPath(projectRoot, file) &&
409
- isTextFile(file, projectRoot)
408
+ isTextFile(file, projectRoot, surfaces)
410
409
  ));
411
410
  } catch (_error) {
412
411
  return [];
@@ -426,22 +425,22 @@ function normalizeScanRoot(root) {
426
425
  return normalizeRepoPath(root).replace(/[/\\]+$/, '');
427
426
  }
428
427
 
429
- function getScanRoots(projectRoot, options = {}) {
430
- const roots = options.scanRoots || getDefaultScanRoots(projectRoot);
428
+ function getScanRoots(projectRoot, options = {}, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
429
+ const roots = options.scanRoots || getDefaultScanRoots(projectRoot, surfaces);
431
430
  return [
432
431
  ...new Set(
433
- [...roots, ...readSyncManifestScanRoots(projectRoot)]
432
+ [...roots, ...readSyncManifestScanRoots(projectRoot, surfaces)]
434
433
  .map(normalizeScanRoot)
435
434
  .filter(Boolean),
436
435
  ),
437
436
  ];
438
437
  }
439
438
 
440
- function isInstructionPath(relativePath, projectRoot) {
441
- return getInstructionPrefixes(projectRoot).some(prefix => relativePath.startsWith(prefix));
439
+ function isInstructionPath(relativePath, projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
440
+ return getInstructionPrefixes(projectRoot, surfaces).some(prefix => relativePath.startsWith(prefix));
442
441
  }
443
442
 
444
- function classifyGroup(relativePath, projectRoot) {
443
+ function classifyGroup(relativePath, projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
445
444
  if (
446
445
  relativePath === 'lefthook.yml' ||
447
446
  relativePath.startsWith('.forge/hooks/') ||
@@ -451,7 +450,7 @@ function classifyGroup(relativePath, projectRoot) {
451
450
  }
452
451
 
453
452
  if (
454
- getSkillPrefixes(projectRoot).some(prefix => relativePath.startsWith(prefix)) ||
453
+ getSkillPrefixes(projectRoot, surfaces).some(prefix => relativePath.startsWith(prefix)) ||
455
454
  relativePath.startsWith('packages/skills/')
456
455
  ) {
457
456
  return 'skills';
@@ -463,8 +462,8 @@ function classifyGroup(relativePath, projectRoot) {
463
462
 
464
463
  if (
465
464
  relativePath.startsWith('docs/') ||
466
- getRootDocFiles(projectRoot).has(relativePath) ||
467
- isInstructionPath(relativePath, projectRoot)
465
+ getRootDocFiles(projectRoot, surfaces).has(relativePath) ||
466
+ isInstructionPath(relativePath, projectRoot, surfaces)
468
467
  ) {
469
468
  return 'docs';
470
469
  }
@@ -534,11 +533,16 @@ function trackedRepoFiles(projectRoot) {
534
533
  }
535
534
 
536
535
  function auditBdCallSites(projectRoot, options = {}) {
537
- const roots = getScanRoots(projectRoot, options);
536
+ const surfaces = collectAgentInstructionSurfaces(projectRoot);
537
+ return auditBdCallSitesWithSurfaces(projectRoot, options, surfaces);
538
+ }
539
+
540
+ function auditBdCallSitesWithSurfaces(projectRoot, options, surfaces) {
541
+ const roots = getScanRoots(projectRoot, options, surfaces);
538
542
  // Only audit git-tracked files, so untracked working-tree pollution (e.g. a concurrent
539
543
  // test's temp files under a scan root) can't perturb the census (issue 1d4077d8).
540
544
  const tracked = trackedRepoFiles(projectRoot);
541
- const files = [...new Set(roots.flatMap(root => walkFiles(projectRoot, root)))]
545
+ const files = [...new Set(roots.flatMap(root => walkFiles(projectRoot, root, surfaces)))]
542
546
  .filter(relativePath => !tracked || tracked.has(normalizeRepoPath(relativePath)))
543
547
  .sort((left, right) => left.localeCompare(right));
544
548
  const groups = newGroupedAudit();
@@ -559,7 +563,7 @@ function auditBdCallSites(projectRoot, options = {}) {
559
563
  }
560
564
  }
561
565
 
562
- addCallSites(groups, classifyGroup(relativePath, projectRoot), relativePath, lineEntries);
566
+ addCallSites(groups, classifyGroup(relativePath, projectRoot, surfaces), relativePath, lineEntries);
563
567
  }
564
568
 
565
569
  return {
@@ -578,13 +582,17 @@ function auditBdCallSites(projectRoot, options = {}) {
578
582
  // SKIP_DIR_NAMES — rather than a parallel list that could drift from the gate.
579
583
  function isBdCensusPath(projectRoot, filePath, options = {}) {
580
584
  const relativePath = safeNormalizeRepoPath(projectRoot, filePath);
581
- if (!relativePath || shouldSkipRelativePath(relativePath) || !isTextFile(relativePath, projectRoot)) {
585
+ if (!relativePath || shouldSkipRelativePath(relativePath)) {
586
+ return false;
587
+ }
588
+ const surfaces = collectAgentInstructionSurfaces(projectRoot);
589
+ if (!isTextFile(relativePath, projectRoot, surfaces)) {
582
590
  return false;
583
591
  }
584
592
 
585
593
  // getScanRoots already returns normalized roots — re-normalizing here would be a second
586
594
  // copy of that rule, free to drift from the one auditBdCallSites walks.
587
- return getScanRoots(projectRoot, options)
595
+ return getScanRoots(projectRoot, options, surfaces)
588
596
  .some(root => {
589
597
  // walkFiles applies no directory skips to a root that is itself a file.
590
598
  if (relativePath === root) {
@@ -738,8 +746,11 @@ function commandModuleHasRegistryContract(projectRoot, name) {
738
746
  hasRegistryCommandHandler(findObjectMember(exportedObject, 'handler'), bindings.functions);
739
747
  }
740
748
 
741
- function isD20HotPath(relativePath, projectRoot) {
742
- const surfaces = collectAgentInstructionSurfaces(projectRoot);
749
+ function isD20HotPath(
750
+ relativePath,
751
+ projectRoot,
752
+ surfaces = collectAgentInstructionSurfaces(projectRoot),
753
+ ) {
743
754
  const hotPathFiles = new Set([
744
755
  ...D20_HOT_PATH_FILES,
745
756
  ...surfaces.rootDocFiles,
@@ -754,14 +765,18 @@ function isD20HotPath(relativePath, projectRoot) {
754
765
  hotPathPrefixes.some(prefix => relativePath.startsWith(prefix));
755
766
  }
756
767
 
757
- function flattenHotPathEvidence(audit, projectRoot) {
768
+ function flattenHotPathEvidence(
769
+ audit,
770
+ projectRoot,
771
+ surfaces = collectAgentInstructionSurfaces(projectRoot),
772
+ ) {
758
773
  const files = [];
759
774
  for (const group of GROUPS) {
760
775
  files.push(...audit.groups[group].files);
761
776
  }
762
777
 
763
778
  return files
764
- .filter(file => isD20HotPath(file.path, projectRoot))
779
+ .filter(file => isD20HotPath(file.path, projectRoot, surfaces))
765
780
  .filter(Boolean)
766
781
  .map(file => ({
767
782
  path: file.path,
@@ -1779,8 +1794,8 @@ function missingArtifactEvidence(relativePath) {
1779
1794
  return [{ path: relativePath, missing: true }];
1780
1795
  }
1781
1796
 
1782
- function hotPathBlocker(audit, projectRoot) {
1783
- const hotPathEvidence = flattenHotPathEvidence(audit, projectRoot);
1797
+ function hotPathBlocker(audit, projectRoot, surfaces) {
1798
+ const hotPathEvidence = flattenHotPathEvidence(audit, projectRoot, surfaces);
1784
1799
  if (hotPathEvidence.length > 0) {
1785
1800
  return {
1786
1801
  id: 'bd-hot-path-issue-commands',
@@ -1966,9 +1981,9 @@ function freshCloneBlocker(projectRoot) {
1966
1981
  return null;
1967
1982
  }
1968
1983
 
1969
- function buildBlockers(projectRoot, audit) {
1984
+ function buildBlockers(projectRoot, audit, surfaces) {
1970
1985
  return [
1971
- hotPathBlocker(audit, projectRoot),
1986
+ hotPathBlocker(audit, projectRoot, surfaces),
1972
1987
  auditArtifactBlocker(projectRoot, audit),
1973
1988
  issueSurfaceBlocker(projectRoot),
1974
1989
  primeBlocker(projectRoot),
@@ -1982,6 +1997,7 @@ function buildBlockers(projectRoot, audit) {
1982
1997
 
1983
1998
  function buildReadinessReport(projectRoot, options = {}) {
1984
1999
  const target = options.target || SUPPORTED_TARGET;
2000
+ const surfaces = collectAgentInstructionSurfaces(projectRoot);
1985
2001
  // Accept prerelease variants (0.1.0-beta.1, 0.1.0-rc.1, …) against their base version's
1986
2002
  // readiness profile — a beta's readiness content is identical to its release.
1987
2003
  const baseTarget = String(target).split('-')[0];
@@ -1995,12 +2011,12 @@ function buildReadinessReport(projectRoot, options = {}) {
1995
2011
  detail: `Unsupported release readiness target: ${target}. Supported target: ${SUPPORTED_TARGET} (prerelease suffixes like -beta.1 are accepted).`,
1996
2012
  evidence: [],
1997
2013
  }],
1998
- audit: auditBdCallSites(projectRoot, options),
2014
+ audit: auditBdCallSitesWithSurfaces(projectRoot, options, surfaces),
1999
2015
  };
2000
2016
  }
2001
2017
 
2002
- const audit = auditBdCallSites(projectRoot, options);
2003
- const blockers = buildBlockers(projectRoot, audit);
2018
+ const audit = auditBdCallSitesWithSurfaces(projectRoot, options, surfaces);
2019
+ const blockers = buildBlockers(projectRoot, audit, surfaces);
2004
2020
 
2005
2021
  return {
2006
2022
  success: blockers.length === 0,
@@ -13,6 +13,7 @@ const path = require('node:path');
13
13
  // (spawnSync ENOENT, kernel issue 9997d516) and patched Node refuses .cmd/.bat
14
14
  // without shell:true (CVE-2024-27980).
15
15
  const WINDOWS_DIRECT_SPAWN_EXTS = new Set(['.exe', '.com']);
16
+ const executableResolutionCache = new Map();
16
17
 
17
18
  // Conservative allowlist for tokens executed through cmd.exe. Blocks every cmd
18
19
  // metacharacter (& | < > ^ % ! " ' ` ( ) ; and whitespace) so shell execution
@@ -56,6 +57,13 @@ function resolveWindowsSpawnSpec(command, candidates) {
56
57
  return { file: command, shell: true };
57
58
  }
58
59
 
60
+ function windowsEnvironmentValue(environment, name) {
61
+ const key = Object.keys(environment)
62
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
63
+ .find((candidate) => candidate.toUpperCase() === name);
64
+ return key ? environment[key] : '';
65
+ }
66
+
59
67
  /**
60
68
  * Securely execute a command with PATH validation.
61
69
  * Mitigates SonarCloud S4036: Ensures executables are from trusted locations.
@@ -71,29 +79,47 @@ function secureExecFileSync(command, args = [], options = {}) {
71
79
  _execFileSync = execFileSync,
72
80
  _spawnSync = spawnSync,
73
81
  _platform = process.platform,
82
+ _resolutionCache = _spawnSync === spawnSync ? executableResolutionCache : null,
74
83
  ...execOptions
75
84
  } = options;
76
85
 
77
86
  const isWindows = _platform === 'win32';
78
87
  const pathResolver = isWindows ? 'where.exe' : 'which';
88
+ const commandEnv = execOptions.env || process.env;
89
+ const lookupDirectory = isWindows ? path.resolve(execOptions.cwd ?? process.cwd()) : undefined;
90
+ const cacheKey = isWindows && _resolutionCache
91
+ ? [
92
+ _platform,
93
+ command,
94
+ windowsEnvironmentValue(commandEnv, 'PATH'),
95
+ windowsEnvironmentValue(commandEnv, 'PATHEXT'),
96
+ lookupDirectory,
97
+ ].join('\0')
98
+ : null;
79
99
 
80
- let candidates = [];
81
- try {
82
- // Resolve command's full path to validate it's in a trusted location
83
- const result = _spawnSync(pathResolver, [command], {
84
- encoding: 'utf8',
85
- stdio: ['ignore', 'pipe', 'ignore']
86
- });
100
+ let candidates = cacheKey ? _resolutionCache.get(cacheKey) : null;
101
+ if (!candidates) {
102
+ candidates = [];
103
+ try {
104
+ // Resolve command's full path to validate it's in a trusted location.
105
+ const result = _spawnSync(pathResolver, [command], {
106
+ cwd: lookupDirectory,
107
+ encoding: 'utf8',
108
+ env: commandEnv,
109
+ stdio: ['ignore', 'pipe', 'ignore']
110
+ });
87
111
 
88
- if (result.status === 0 && result.stdout) {
89
- // Handle both CRLF (Windows) and LF (Unix) line endings
90
- candidates = result.stdout
91
- .trim()
92
- .split(/\r?\n/)
93
- .map((line) => line.trim())
94
- .filter(Boolean);
112
+ if (result.status === 0 && result.stdout) {
113
+ // Handle both CRLF (Windows) and LF (Unix) line endings
114
+ candidates = result.stdout
115
+ .trim()
116
+ .split(/\r?\n/)
117
+ .map((line) => line.trim())
118
+ .filter(Boolean);
119
+ if (cacheKey && candidates.length > 0) _resolutionCache.set(cacheKey, candidates);
120
+ }
121
+ } catch (_err) { // NOSONAR - S2486: Intentionally ignored; falls back to direct command execution below
95
122
  }
96
- } catch (_err) { // NOSONAR - S2486: Intentionally ignored; falls back to direct command execution below
97
123
  }
98
124
 
99
125
  if (isWindows) {