@@ -67,10 +67,19 @@ function collectReferences(content, knownNames) {
6767 return Array . from ( refs ) . sort ( )
6868}
6969
70+ /**
71+ * Collect hard dependency skill ids from SKILL.md body.
72+ * Only mandatory language counts — plain "引用 `memory` Skill" does not.
73+ * CR-01 / AUD-038: expand beyond "必须读取" so kernel chains are visible in the graph.
74+ */
7075function collectDependencies ( content , knownNames ) {
7176 const dependencies = new Set ( )
77+ const mandatoryLine =
78+ / (?: 必 须 (?: 继 续 | 先 ) ? (?: 读 取 | 调 用 | 加 载 | 执 行 ) | 应 当 (?: 先 ) ? (?: 读 取 | 调 用 | 加 载 ) | 需 要 先 (?: 读 取 | 调 用 ) | 先 (?: 读 取 | 调 用 ) | 再 (?: 读 取 | 调 用 ) | d e p e n d s ? \s + o n | m u s t \s + (?: f i r s t \s + ) ? (?: r e a d | l o a d | c a l l | i n v o k e ) | r e q u i r e d \s + (?: t o \s + ) ? (?: r e a d | l o a d ) | 依 赖 ) / i
79+ const negativeLine = / (?: 不 得 | 禁 止 | 不 要 | 不 是 | 无 需 | 不 必 | n o t \s + r e q u i r e d | d o \s + n o t ) / i
7280 for ( const line of content . split ( / \r ? \n / ) ) {
73- if ( ! / (?: 必 须 (?: 继 续 | 先 ) ? 读 取 | m u s t \s + (?: f i r s t \s + ) ? r e a d ) / i. test ( line ) ) continue
81+ if ( ! mandatoryLine . test ( line ) ) continue
82+ if ( negativeLine . test ( line ) ) continue
7483 for ( const match of line . matchAll ( / ` ( [ a - z 0 - 9 - ] + ) ` | (?: s k i l l s \/ | \. \. \/ ) ( [ a - z 0 - 9 - ] + ) \/ S K I L L \. m d / g) ) {
7584 const name = match [ 1 ] || match [ 2 ]
7685 if ( knownNames . has ( name ) ) dependencies . add ( name )
@@ -79,19 +88,95 @@ function collectDependencies(content, knownNames) {
7988 return Array . from ( dependencies ) . sort ( )
8089}
8190
82- function buildTriggerContract ( id , description ) {
91+ /**
92+ * Merge body-derived deps with optional portfolio-evidence.json overrides (explicit requires).
93+ */
94+ function mergeDependencies ( bodyDeps , overrideDeps , knownNames , selfId ) {
95+ const out = new Set ( bodyDeps )
96+ for ( const name of Array . isArray ( overrideDeps ) ? overrideDeps : [ ] ) {
97+ if ( typeof name === 'string' && knownNames . has ( name ) && name !== selfId ) out . add ( name )
98+ }
99+ return Array . from ( out ) . sort ( )
100+ }
101+
102+ function buildTriggerContract ( id , description , override = { } ) {
83103 const terms = new Set ( [ id ] )
84104 for ( const match of description . matchAll ( / ` ( [ A - Z a - z 0 - 9 - ] + ) ` | \b ( [ A - Z a - z ] [ A - Z a - z 0 - 9 - ] { 2 , } ) \b / g) ) {
85105 terms . add ( match [ 1 ] || match [ 2 ] )
86106 }
107+ // Optional evidence terms (do not invent semantics beyond provided strings)
108+ for ( const term of Array . isArray ( override . triggerTerms ) ? override . triggerTerms : [ ] ) {
109+ if ( typeof term === 'string' && term . trim ( ) ) terms . add ( term . trim ( ) )
110+ }
111+ const positive = Array . isArray ( override . triggerPositive ) && override . triggerPositive . length
112+ ? override . triggerPositive . map ( ( item , index ) => ( {
113+ fixture : item . fixture || `evidence-positive-${ index + 1 } ` ,
114+ input : item . input != null ? String ( item . input ) : description
115+ } ) )
116+ : [ { fixture : 'frontmatter-description-resolves' , input : description } ]
117+ const negative = Array . isArray ( override . triggerNegative ) && override . triggerNegative . length
118+ ? override . triggerNegative . map ( ( item , index ) => ( {
119+ fixture : item . fixture || `evidence-negative-${ index + 1 } ` ,
120+ input : item . input != null ? String ( item . input ) : ''
121+ } ) )
122+ : [ { fixture : 'empty-or-unregistered-trigger-rejected' , input : '' } ]
123+ const ambiguous = Array . isArray ( override . triggerAmbiguous )
124+ ? override . triggerAmbiguous . map ( ( item , index ) => ( {
125+ fixture : item . fixture || `evidence-ambiguous-${ index + 1 } ` ,
126+ input : item . input != null ? String ( item . input ) : ''
127+ } ) )
128+ : [ ]
87129 return {
88130 terms : Array . from ( terms ) . sort ( ) ,
89- positive : [ { fixture : 'frontmatter-description-resolves' , input : description } ] ,
90- negative : [ { fixture : 'empty-or-unregistered-trigger-rejected' , input : '' } ] ,
91- ambiguous : [ ]
131+ positive,
132+ negative,
133+ ambiguous
134+ }
135+ }
136+
137+ /**
138+ * Resolve trigger precision from portfolio-evidence override.
139+ * Measured requires sampleCount > 0 and numeric precision in [0, 1].
140+ * CR-01 / AUD-039: stop hardcoding structural-only when real evidence is supplied.
141+ */
142+ function resolveTriggerPrecision ( override = { } , evidenceDate = null ) {
143+ const raw = override . triggerPrecision
144+ if ( raw && raw . state === 'measured' ) {
145+ const sampleCount = Number ( raw . sampleCount )
146+ const precision = Number ( raw . precision )
147+ if ( Number . isInteger ( sampleCount ) && sampleCount > 0 && Number . isFinite ( precision ) && precision >= 0 && precision <= 1 ) {
148+ return {
149+ state : 'measured' ,
150+ sampleCount,
151+ precision,
152+ falsePositiveRate : raw . falsePositiveRate == null ? null : Number ( raw . falsePositiveRate ) ,
153+ falseNegativeRate : raw . falseNegativeRate == null ? null : Number ( raw . falseNegativeRate ) ,
154+ manualCorrectionRate : raw . manualCorrectionRate == null ? null : Number ( raw . manualCorrectionRate ) ,
155+ lastMeasuredAt : raw . lastMeasuredAt || evidenceDate || null
156+ }
157+ }
158+ }
159+ const fixtureSampleCount =
160+ ( Array . isArray ( override . triggerPositive ) ? override . triggerPositive . length : 0 ) +
161+ ( Array . isArray ( override . triggerNegative ) ? override . triggerNegative . length : 0 )
162+ return {
163+ state : 'structural-only' ,
164+ sampleCount : fixtureSampleCount > 0 ? fixtureSampleCount : 0 ,
165+ precision : null ,
166+ falsePositiveRate : null ,
167+ falseNegativeRate : null ,
168+ manualCorrectionRate : null ,
169+ lastMeasuredAt : fixtureSampleCount > 0 ? ( override . triggerPrecision && override . triggerPrecision . lastMeasuredAt ) || evidenceDate || null : null
92170 }
93171}
94172
173+ function summarizeTriggerQuality ( skills ) {
174+ const measured = skills . filter ( skill => skill . evidence && skill . evidence . triggerPrecision && skill . evidence . triggerPrecision . state === 'measured' ) . length
175+ if ( measured === 0 ) return 'structural-only'
176+ if ( measured === skills . length ) return 'measured'
177+ return 'mixed'
178+ }
179+
95180function classifyConsumer ( relativePath ) {
96181 if ( / ^ (?: c h a n g e l o g s \/ r e l e a s e s \/ | w e b s i t e \/ d o c s \/ v e r s i o n s \/ v 1 \/ 1 \. 0 \. 0 \/ ) / . test ( relativePath ) ) {
97182 return 'historical'
@@ -527,15 +612,16 @@ function buildPortfolio(root, options = {}) {
527612 consumerProjectionRows . push ( `${ id } :${ consumer . path } :${ consumer . role } ` )
528613 }
529614 const references = collectReferences ( content , knownNames ) . filter ( name => name !== id )
530- const dependencies = collectDependencies ( content , knownNames ) . filter ( name => name !== id )
615+ const override = portfolioEvidence . skills [ id ] || { }
616+ const bodyDependencies = collectDependencies ( content , knownNames ) . filter ( name => name !== id )
617+ const dependencies = mergeDependencies ( bodyDependencies , override . dependencies , knownNames , id )
531618 const validationProfile = consumerRows
532619 . filter ( item => item . role === 'current' && / ^ s c r i p t s \/ (?: t e s t - | v a l i d a t e ) / . test ( item . path ) )
533620 . map ( item => item . path )
534621 for ( const fixture of portfolioEvidence . defaults . validationProfile ) {
535622 if ( ! validationProfile . includes ( fixture ) ) validationProfile . push ( fixture )
536623 }
537624 validationProfile . sort ( )
538- const override = portfolioEvidence . skills [ id ] || { }
539625 const currentConsumer = consumerRows . find ( item => item . role === 'current' )
540626 const operationalReadiness = {
541627 state : currentConsumer && isRegistered ? 'complete' : 'incomplete' ,
@@ -549,9 +635,11 @@ function buildPortfolio(root, options = {}) {
549635 const hash = sha256 ( canonicalContent )
550636 const sourceBytes = Buffer . byteLength ( canonicalContent , 'utf8' )
551637 sourceRows . push ( `${ source } :${ hash } ` )
638+ const triggers = buildTriggerContract ( id , frontmatter . description , override )
639+ const triggerPrecision = resolveTriggerPrecision ( override , portfolioEvidence . evidenceDate )
552640 const skillIndex = buildSkillIndex ( {
553641 id,
554- triggers : buildTriggerContract ( id , frontmatter . description ) ,
642+ triggers,
555643 dependencies,
556644 conflicts,
557645 validationProfile,
@@ -588,15 +676,7 @@ function buildPortfolio(root, options = {}) {
588676 evidence : {
589677 registration : isRegistered ? 'plugin.json' : null ,
590678 operationalReadiness,
591- triggerPrecision : {
592- state : 'structural-only' ,
593- sampleCount : 0 ,
594- precision : null ,
595- falsePositiveRate : null ,
596- falseNegativeRate : null ,
597- manualCorrectionRate : null ,
598- lastMeasuredAt : null
599- } ,
679+ triggerPrecision,
600680 lifecycleAuthorization : 'plugin.json#skills[].lifecycleState' ,
601681 stateRationale : override . stateRationale || portfolioEvidence . defaults . stateRationale ,
602682 promotionCriteria : override . promotionCriteria || portfolioEvidence . defaults . promotionCriteria
@@ -625,6 +705,13 @@ function buildPortfolio(root, options = {}) {
625705 `consumer-inventory:${ consumerInventoryDigest } ` ,
626706 `consumer-projection:${ consumerProjectionDigest } `
627707 ] . join ( '\n' ) )
708+ const triggerPrecisionMeasuredCount = skills . filter ( skill => skill . evidence . triggerPrecision . state === 'measured' ) . length
709+ const triggerQuality = summarizeTriggerQuality ( skills )
710+ const evidenceNote = triggerQuality === 'structural-only'
711+ ? 'Operational lifecycle evidence is complete for registered current consumers; trigger precision remains structural-only until measured samples are supplied via portfolio-evidence.json.'
712+ : triggerQuality === 'mixed'
713+ ? `Operational evidence complete; trigger precision mixed (${ triggerPrecisionMeasuredCount } /${ skills . length } measured via portfolio-evidence.json).`
714+ : 'Operational evidence complete; all skills carry measured trigger precision samples.'
628715
629716 return {
630717 schemaVersion : 2 ,
@@ -655,17 +742,17 @@ function buildPortfolio(root, options = {}) {
655742 dependencyEdgeCount : edges . length ,
656743 conflictReviewedCount : skills . filter ( skill => skill . conflictReview . status === 'reviewed-none' || skill . conflictReview . status === 'declared' ) . length ,
657744 operationalEvidenceCompleteCount : skills . filter ( skill => skill . evidence . operationalReadiness . state === 'complete' ) . length ,
658- triggerPrecisionMeasuredCount : skills . filter ( skill => skill . evidence . triggerPrecision . state === 'measured' ) . length ,
745+ triggerPrecisionMeasuredCount,
659746 instructionBudgetP95Bytes : percentile ( skillPaths . map ( relative => Buffer . byteLength ( canonicalizeTextForDigest ( readText ( relative ) ) , 'utf8' ) ) , 0.95 ) ,
660- triggerQuality : 'structural-only' ,
747+ triggerQuality,
661748 sidecarPresentCount : skills . filter ( skill => skill . sidecar && skill . sidecar . state === 'valid' ) . length
662749 } ,
663750 dependencyGraph : { nodes, edges, cycles } ,
664751 referenceGraph : { edges : referenceEdges . sort ( ( a , b ) => `${ a . from } :${ a . to } ` . localeCompare ( `${ b . from } :${ b . to } ` ) ) } ,
665752 health : {
666753 orphanActive,
667754 lifecycleMutationAllowed : false ,
668- evidenceNote : 'Operational lifecycle evidence is complete for registered current consumers; trigger precision remains structural-only until real samples exist.'
755+ evidenceNote
669756 } ,
670757 skills
671758 }
@@ -706,6 +793,9 @@ function validatePortfolio(portfolio) {
706793 const triggerPrecision = skill . evidence && skill . evidence . triggerPrecision
707794 if ( ! triggerPrecision || ! [ 'structural-only' , 'measured' ] . includes ( triggerPrecision . state ) ) errors . push ( `invalid trigger precision state: ${ skill . id } ` )
708795 if ( triggerPrecision && triggerPrecision . state === 'measured' && triggerPrecision . sampleCount <= 0 ) errors . push ( `measured trigger precision lacks samples: ${ skill . id } ` )
796+ if ( triggerPrecision && triggerPrecision . state === 'measured' && ( typeof triggerPrecision . precision !== 'number' || triggerPrecision . precision < 0 || triggerPrecision . precision > 1 ) ) {
797+ errors . push ( `measured trigger precision missing precision in [0,1]: ${ skill . id } ` )
798+ }
709799 const index = skill . skillIndex
710800 if ( ! index || index . id !== skill . id || index . type !== 'skill' ) errors . push ( `invalid SkillIndexV2 identity: ${ skill . id } ` )
711801 for ( const field of [ 'workflow' , 'phase' , 'domains' , 'requires' , 'conflictsWith' , 'fixtures' , 'probeSuiteRefs' ] ) {
@@ -720,6 +810,9 @@ function validatePortfolio(portfolio) {
720810 errors . push ( 'dependency graph contains cycles' )
721811 }
722812 if ( portfolio . summary && portfolio . summary . skillCount !== ids . size ) errors . push ( 'summary skillCount mismatch' )
813+ if ( portfolio . summary && ! [ 'structural-only' , 'mixed' , 'measured' ] . includes ( portfolio . summary . triggerQuality ) ) {
814+ errors . push ( `invalid summary triggerQuality: ${ portfolio . summary . triggerQuality } ` )
815+ }
723816 return errors
724817}
725818
@@ -735,6 +828,9 @@ module.exports = {
735828 buildTriggerContract,
736829 canonicalizeTextForDigest,
737830 collectDependencies,
831+ mergeDependencies,
832+ resolveTriggerPrecision,
833+ summarizeTriggerQuality,
738834 detectCycles,
739835 gitIndexSnapshot,
740836 gitLsFiles,
0 commit comments