@@ -281,15 +281,40 @@ export async function clearRuntime(nitDir: string): Promise<void> {
281281function parseConfig ( raw : string ) : NitConfig {
282282 const remotes : Record < string , NitRemoteConfig > = { } ;
283283 const rpc : Record < string , NitRpcConfig > = { } ;
284- let currentSection : string | null = null ;
284+ let currentSection : 'remote' | 'rpc' | 'nit' | 'skills' | 'runtime' | null = null ;
285285 let currentRemote : string | null = null ;
286286 let currentRpcChain : string | null = null ;
287287 let currentNitSubsection : string | null = null ;
288288 let skillsDir : string | undefined ;
289289 const nitSkillFields : Partial < Record < 'source' | 'url' , string > > = { } ;
290290 const runtimeFields : Partial < Record < 'provider' | 'model' | 'harness' | 'declared_at' , string > > = { } ;
291+ const seenKeys = new Set < string > ( ) ;
292+ const rpcSections = new Map < string , number > ( ) ;
293+ let skillsSectionLine : number | null = null ;
294+ let nitSkillSectionLine : number | null = null ;
295+ let runtimeSectionLine : number | null = null ;
296+
297+ const fail = ( lineNo : number , message : string ) : never => {
298+ throw new Error ( `Invalid .nit/config line ${ lineNo } : ${ message } ` ) ;
299+ } ;
300+ const validateAtLine = ( lineNo : number , validate : ( ) => void ) : void => {
301+ try {
302+ validate ( ) ;
303+ } catch ( error ) {
304+ fail ( lineNo , error instanceof Error ? error . message : String ( error ) ) ;
305+ }
306+ } ;
307+ const markKey = ( lineNo : number , sectionKey : string , key : string ) : void => {
308+ const marker = `${ sectionKey } .${ key } ` ;
309+ if ( seenKeys . has ( marker ) ) {
310+ fail ( lineNo , `duplicate key "${ key } "` ) ;
311+ }
312+ seenKeys . add ( marker ) ;
313+ } ;
291314
292- for ( const line of raw . split ( '\n' ) ) {
315+ const lines = raw . split ( '\n' ) ;
316+ for ( const [ index , line ] of lines . entries ( ) ) {
317+ const lineNo = index + 1 ;
293318 const trimmed = line . trim ( ) ;
294319
295320 // Skip empty lines and comments
@@ -300,6 +325,7 @@ function parseConfig(raw: string): NitConfig {
300325 if ( remoteMatch ) {
301326 currentSection = 'remote' ;
302327 currentRemote = remoteMatch [ 1 ] ;
328+ validateAtLine ( lineNo , ( ) => validateRemoteName ( currentRemote ! ) ) ;
303329 currentRpcChain = null ;
304330 currentNitSubsection = null ;
305331 if ( ! remotes [ currentRemote ] ) {
@@ -313,6 +339,8 @@ function parseConfig(raw: string): NitConfig {
313339 if ( rpcMatch ) {
314340 currentSection = 'rpc' ;
315341 currentRpcChain = rpcMatch [ 1 ] ;
342+ validateAtLine ( lineNo , ( ) => validateRpcChainName ( currentRpcChain ! ) ) ;
343+ rpcSections . set ( currentRpcChain , lineNo ) ;
316344 currentRemote = null ;
317345 currentNitSubsection = null ;
318346 continue ;
@@ -321,8 +349,12 @@ function parseConfig(raw: string): NitConfig {
321349 // Section header: [nit "skill"]
322350 const nitMatch = trimmed . match ( / ^ \[ n i t \s + " ( [ ^ " ] + ) " \] $ / ) ;
323351 if ( nitMatch ) {
352+ if ( nitMatch [ 1 ] !== 'skill' ) {
353+ fail ( lineNo , `unknown nit subsection "${ nitMatch [ 1 ] } "` ) ;
354+ }
324355 currentSection = 'nit' ;
325356 currentNitSubsection = nitMatch [ 1 ] ;
357+ nitSkillSectionLine = lineNo ;
326358 currentRemote = null ;
327359 currentRpcChain = null ;
328360 continue ;
@@ -331,6 +363,7 @@ function parseConfig(raw: string): NitConfig {
331363 // Section header: [skills]
332364 if ( trimmed === '[skills]' ) {
333365 currentSection = 'skills' ;
366+ skillsSectionLine = lineNo ;
334367 currentRemote = null ;
335368 currentRpcChain = null ;
336369 currentNitSubsection = null ;
@@ -340,39 +373,90 @@ function parseConfig(raw: string): NitConfig {
340373 // Section header: [runtime]
341374 if ( trimmed === '[runtime]' ) {
342375 currentSection = 'runtime' ;
376+ runtimeSectionLine = lineNo ;
343377 currentRemote = null ;
344378 currentRpcChain = null ;
345379 currentNitSubsection = null ;
346380 continue ;
347381 }
348382
383+ if ( trimmed . startsWith ( '[' ) ) {
384+ fail ( lineNo , `unknown section "${ trimmed } "` ) ;
385+ }
386+
349387 // Key-value pair: key = value
350388 const kvMatch = trimmed . match ( / ^ ( \w + ) \s * = \s * ( .+ ) $ / ) ;
351389 if ( kvMatch ) {
352390 const [ , key , value ] = kvMatch ;
391+ const parsedValue = value . trim ( ) ;
392+ if ( ! currentSection ) {
393+ fail ( lineNo , `key "${ key } " appears before any section` ) ;
394+ }
353395 if ( currentSection === 'remote' && currentRemote !== null ) {
396+ markKey ( lineNo , `remote.${ currentRemote } ` , key ) ;
354397 if ( key === 'url' ) {
355- remotes [ currentRemote ] . url = value . trim ( ) ;
398+ validateAtLine ( lineNo , ( ) => validateHttpUrl ( parsedValue , `Remote URL for "${ currentRemote } "` ) ) ;
399+ remotes [ currentRemote ] . url = parsedValue ;
356400 } else if ( key === 'credential' ) {
357- remotes [ currentRemote ] . credential = value . trim ( ) ;
401+ validateAtLine ( lineNo , ( ) => validateConfigValue ( parsedValue , `Remote credential for "${ currentRemote } "` ) ) ;
402+ remotes [ currentRemote ] . credential = parsedValue ;
403+ } else {
404+ fail ( lineNo , `unknown remote key "${ key } "` ) ;
358405 }
359406 } else if ( currentSection === 'rpc' && currentRpcChain !== null ) {
407+ markKey ( lineNo , `rpc.${ currentRpcChain } ` , key ) ;
360408 if ( key === 'url' ) {
361- rpc [ currentRpcChain ] = { url : value . trim ( ) } ;
409+ validateAtLine ( lineNo , ( ) => validateHttpUrl ( parsedValue , `RPC URL for "${ currentRpcChain } "` ) ) ;
410+ rpc [ currentRpcChain ] = { url : parsedValue } ;
411+ } else {
412+ fail ( lineNo , `unknown rpc key "${ key } "` ) ;
362413 }
363414 } else if ( currentSection === 'skills' ) {
415+ markKey ( lineNo , 'skills' , key ) ;
364416 if ( key === 'dir' ) {
365- skillsDir = value . trim ( ) ;
417+ validateAtLine ( lineNo , ( ) => validateConfigValue ( parsedValue , 'Skills directory' ) ) ;
418+ skillsDir = parsedValue ;
419+ } else {
420+ fail ( lineNo , `unknown skills key "${ key } "` ) ;
366421 }
367422 } else if ( currentSection === 'runtime' ) {
423+ markKey ( lineNo , 'runtime' , key ) ;
368424 if ( key === 'provider' || key === 'model' || key === 'harness' || key === 'declared_at' ) {
369- runtimeFields [ key ] = value . trim ( ) ;
425+ runtimeFields [ key ] = parsedValue ;
426+ } else {
427+ fail ( lineNo , `unknown runtime key "${ key } "` ) ;
370428 }
371429 } else if ( currentSection === 'nit' && currentNitSubsection === 'skill' ) {
430+ markKey ( lineNo , 'nit.skill' , key ) ;
372431 if ( key === 'source' || key === 'url' ) {
373- nitSkillFields [ key ] = value . trim ( ) ;
432+ nitSkillFields [ key ] = parsedValue ;
433+ } else {
434+ fail ( lineNo , `unknown nit skill key "${ key } "` ) ;
374435 }
375436 }
437+ continue ;
438+ }
439+
440+ fail ( lineNo , `malformed line "${ trimmed } "` ) ;
441+ }
442+
443+ for ( const [ chain , lineNo ] of rpcSections . entries ( ) ) {
444+ if ( ! rpc [ chain ] ?. url ) {
445+ fail ( lineNo , `rpc "${ chain } " requires a url` ) ;
446+ }
447+ }
448+ if ( skillsSectionLine !== null && ! skillsDir ) {
449+ fail ( skillsSectionLine , 'skills section requires dir' ) ;
450+ }
451+ if ( nitSkillSectionLine !== null && ! nitSkillFields . source ) {
452+ fail ( nitSkillSectionLine , 'nit skill section requires source' ) ;
453+ }
454+ if ( runtimeSectionLine !== null ) {
455+ const requiredRuntimeKeys = [ 'provider' , 'model' , 'harness' , 'declared_at' ] as const ;
456+ for ( const key of requiredRuntimeKeys ) {
457+ if ( ! runtimeFields [ key ] ) {
458+ fail ( runtimeSectionLine , `runtime section requires ${ key } ` ) ;
459+ }
376460 }
377461 }
378462
@@ -381,15 +465,26 @@ function parseConfig(raw: string): NitConfig {
381465 config . rpc = rpc ;
382466 }
383467 if ( runtimeFields . provider && runtimeFields . model && runtimeFields . harness && runtimeFields . declared_at ) {
384- const declaredAt = parseInt ( runtimeFields . declared_at , 10 ) ;
385- if ( Number . isFinite ( declaredAt ) ) {
386- config . runtime = {
387- provider : runtimeFields . provider ,
388- model : runtimeFields . model ,
389- harness : runtimeFields . harness ,
390- declared_at : declaredAt ,
391- } ;
468+ const lineNo = runtimeSectionLine ?? 1 ;
469+ validateAtLine ( lineNo , ( ) => validateRuntimeField ( runtimeFields . provider ! , 'runtime.provider' ) ) ;
470+ validateAtLine ( lineNo , ( ) => validateRuntimeField ( runtimeFields . model ! , 'runtime.model' ) ) ;
471+ validateAtLine ( lineNo , ( ) => validateRuntimeField ( runtimeFields . harness ! , 'runtime.harness' ) ) ;
472+ if ( ! PROVIDER_RE . test ( runtimeFields . provider ) ) {
473+ fail ( lineNo , 'runtime.provider must contain only lowercase letters, digits, and hyphens' ) ;
474+ }
475+ if ( ! / ^ \d + $ / . test ( runtimeFields . declared_at ) ) {
476+ fail ( lineNo , 'runtime.declared_at must be an integer' ) ;
392477 }
478+ const declaredAt = Number ( runtimeFields . declared_at ) ;
479+ if ( ! Number . isSafeInteger ( declaredAt ) ) {
480+ fail ( lineNo , 'runtime.declared_at must be a safe integer' ) ;
481+ }
482+ config . runtime = {
483+ provider : runtimeFields . provider ,
484+ model : runtimeFields . model ,
485+ harness : runtimeFields . harness ,
486+ declared_at : declaredAt ,
487+ } ;
393488 }
394489 if ( nitSkillFields . source ) {
395490 const nitSkill : NitSkillConfig = {
@@ -398,7 +493,7 @@ function parseConfig(raw: string): NitConfig {
398493 if ( nitSkillFields . url ) {
399494 nitSkill . url = nitSkillFields . url ;
400495 }
401- validateNitSkillConfig ( nitSkill ) ;
496+ validateAtLine ( nitSkillSectionLine ?? 1 , ( ) => validateNitSkillConfig ( nitSkill ) ) ;
402497 config . nitSkill = nitSkill ;
403498 }
404499 return config ;
0 commit comments