11#!/usr/bin/env node
22
33import { createHash } from 'node:crypto' ;
4- import { spawnSync } from 'node:child_process' ;
4+ import { execFileSync , spawnSync } from 'node:child_process' ;
55import { existsSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs' ;
66import { join , resolve } from 'node:path' ;
77import { tmpdir } from 'node:os' ;
@@ -12,6 +12,8 @@ export const BOOTSTRAP_PR_TITLE = '[squad] Cast your Squad';
1212export const BOOTSTRAP_ISSUE_TITLE = '[Research Proposals] Agent-discovered repo opportunities' ;
1313export const BOOTSTRAP_ISSUE_MARKER = '<!-- squad:bootstrap-opportunities schema=1 -->' ;
1414export const BOOTSTRAP_RESEARCH_TITLE = '## 🔬 Squad Research — Bootstrap proposals' ;
15+ export const RESEARCH_SCOPE_PATH = '.squad/research-scope.json' ;
16+ export const RESEARCH_SCOPE_SCHEMA = 'squad-research-scope/v1' ;
1517export const CREATE_PR_PERMISSION_DENIED_TEXT =
1618 'GitHub Actions is not permitted to create or approve pull requests' ;
1719export const BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE =
@@ -731,10 +733,62 @@ function parseProposalSections(issueBody, errors) {
731733 } ) ;
732734}
733735
734- function validateProposalSections ( issueBody , root , errors ) {
736+ // The scope is read only from committed HEAD so generated output cannot widen or replace it.
737+ export function readResearchScope ( gitRoot , errors ) {
738+ let committed ;
739+ try {
740+ committed = execFileSync (
741+ 'git' ,
742+ [ 'ls-tree' , '--name-only' , 'HEAD' , '--' , RESEARCH_SCOPE_PATH ] ,
743+ { cwd : gitRoot , encoding : 'utf8' , stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ,
744+ ) . trim ( ) ;
745+ } catch {
746+ return null ;
747+ }
748+ if ( committed !== RESEARCH_SCOPE_PATH ) return null ;
749+ let value ;
750+ try {
751+ value = JSON . parse ( execFileSync (
752+ 'git' ,
753+ [ 'show' , `HEAD:${ RESEARCH_SCOPE_PATH } ` ] ,
754+ { cwd : gitRoot , encoding : 'utf8' , stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ,
755+ ) ) ;
756+ } catch ( error ) {
757+ errors . push ( `research scope: ${ RESEARCH_SCOPE_PATH } is not valid JSON (${ error . message } )` ) ;
758+ return null ;
759+ }
760+ const keys = value && typeof value === 'object' && ! Array . isArray ( value ) ? Object . keys ( value ) . sort ( ) : [ ] ;
761+ const roots = Array . isArray ( value ?. evidence_roots ) ? value . evidence_roots : [ ] ;
762+ const normalized = roots . map ( ( entry ) => normalizePath ( typeof entry === 'string' ? entry . replace ( / \/ + $ / , '' ) : entry ) ) ;
763+ if (
764+ value ?. schema !== RESEARCH_SCOPE_SCHEMA ||
765+ keys . some ( ( key ) => ! [ 'schema' , 'evidence_roots' , 'description' ] . includes ( key ) ) ||
766+ ( value . description !== undefined && typeof value . description !== 'string' ) ||
767+ roots . length === 0 ||
768+ roots . length > 20 ||
769+ normalized . some ( ( entry ) => ! entry ) ||
770+ new Set ( normalized ) . size !== normalized . length
771+ ) {
772+ errors . push ( `research scope: ${ RESEARCH_SCOPE_PATH } must be {"schema":"${ RESEARCH_SCOPE_SCHEMA } ","evidence_roots":[1-20 unique repository-relative paths],"description"?:string}` ) ;
773+ return null ;
774+ }
775+ return normalized ;
776+ }
777+
778+ function underRoot ( path , roots ) {
779+ return roots . some ( ( root ) => path === root || path . startsWith ( `${ root } /` ) ) ;
780+ }
781+
782+ function validateProposalSections ( issueBody , root , errors , scopeRoots = null ) {
735783 const proposals = parseProposalSections ( issueBody , errors ) ;
736784 for ( const proposal of proposals ) {
737785 const { id, evidencePaths } = proposal ;
786+ if ( scopeRoots && ! evidencePaths . some ( ( path ) => {
787+ const normalized = normalizePath ( path ) ;
788+ return normalized && underRoot ( normalized , scopeRoots ) ;
789+ } ) ) {
790+ errors . push ( `issue: ${ id } must cite evidence under a research scope root (${ scopeRoots . join ( ', ' ) } )` ) ;
791+ }
738792 for ( const evidencePath of evidencePaths ) {
739793 const normalized = normalizePath ( evidencePath ) ;
740794 if ( ! normalized || ! existsSync ( join ( root , normalized ) ) ) {
@@ -950,7 +1004,7 @@ export function validateBootstrapPayload({
9501004 }
9511005 }
9521006
953- validateProposalSections ( issueBody , root , errors ) ;
1007+ validateProposalSections ( issueBody , root , errors , readResearchScope ( gitRoot , errors ) ) ;
9541008 return [ ...new Set ( errors ) ] . sort ( ) ;
9551009}
9561010
0 commit comments