66 * validate` / `os build` / `os lint`, and their exit statuses — are pinned by
77 * `test/jsx-gate-manifest-notice.e2e.test.ts`, which runs NIGHTLY (it spawns
88 * the CLI). This file is the per-PR guard, so every rule those faces read is
9- * pinned HERE too — the package-carried layout included.
9+ * pinned HERE too — the package-carried layout included, and (#20166) the
10+ * project directory the manifest is read in: the config's, not the invoker's.
1011 *
1112 * ⛔ Anchors, not prose: the notice is found by its `rule` id and asserted on
1213 * the DATA it must carry (the page count and every place looked), never on the
@@ -18,7 +19,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
1819import { createRequire } from 'node:module' ;
1920import { tmpdir } from 'node:os' ;
2021import { dirname , join } from 'node:path' ;
21- import { pathToFileURL } from 'node:url' ;
22+ import { fileURLToPath , pathToFileURL } from 'node:url' ;
2223import { validateJsxPages } from '@objectstack/lint' ;
2324import {
2425 CONSOLE_SDUI_MANIFEST ,
@@ -60,6 +61,9 @@ const PACKAGE_CARRIED: Record<string, Record<string, unknown>> = {
6061 } ,
6162} ;
6263
64+ /** The project directory the injected answers below were made for. */
65+ const PROJECT_DIR = '/proj' ;
66+
6367const ABSENT : SduiManifestResolution = {
6468 status : 'absent' ,
6569 lookedAt : [ '/proj/sdui.manifest.json' , CONSOLE_SDUI_MANIFEST ] ,
@@ -214,7 +218,7 @@ describe('the console leg — the copy @objectstack/console ships is reached, th
214218 try {
215219 let thrown : unknown ;
216220 try {
217- resolveJsxGateManifest ( HTML_STACK , r ) ;
221+ resolveJsxGateManifest ( HTML_STACK , project , r ) ;
218222 } catch ( e ) {
219223 thrown = e ;
220224 }
@@ -338,13 +342,14 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
338342 } ) ;
339343
340344 it ( 'resolved: arms the gate and says nothing' , ( ) => {
341- const r = resolveJsxGateManifest ( HTML_STACK , { status : 'resolved' , manifest : MANIFEST , path : '/p' } ) ;
345+ const r = resolveJsxGateManifest ( HTML_STACK , PROJECT_DIR , { status : 'resolved' , manifest : MANIFEST , path : '/p' } ) ;
342346 expect ( r ) . toEqual ( { sduiManifest : MANIFEST , notices : [ ] } ) ;
343347 } ) ;
344348
345349 it ( 'absent with a page to check: parse level, and ONE notice carrying the count and every place looked' , ( ) => {
346350 const r = resolveJsxGateManifest (
347351 { pages : [ ...HTML_STACK . pages , { name : 'b' , kind : 'jsx' , source : '<div />' } ] } ,
352+ PROJECT_DIR ,
348353 ABSENT ,
349354 ) ;
350355 expect ( r . sduiManifest ) . toBeUndefined ( ) ;
@@ -362,7 +367,7 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
362367 it . each ( Object . entries ( PACKAGE_CARRIED ) ) (
363368 'absent, %s beside package-carried html pages: the notice, counting the package page' ,
364369 ( _label , stack ) => {
365- const r = resolveJsxGateManifest ( stack , ABSENT ) ;
370+ const r = resolveJsxGateManifest ( stack , PROJECT_DIR , ABSENT ) ;
366371 expect ( r . sduiManifest ) . toBeUndefined ( ) ;
367372 expect ( r . notices ) . toHaveLength ( 1 ) ;
368373 expect ( r . notices [ 0 ] ) . toMatchObject ( { severity : 'info' , rule : JSX_PARSE_LEVEL_ONLY_RULE } ) ;
@@ -373,26 +378,30 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
373378 it . each ( Object . entries ( PACKAGE_CARRIED ) ) (
374379 'unusable, %s beside package-carried html pages: refused, not waved through' ,
375380 ( _label , stack ) => {
376- expect ( ( ) => resolveJsxGateManifest ( stack , UNUSABLE ) ) . toThrow ( SduiManifestRefusalError ) ;
381+ expect ( ( ) => resolveJsxGateManifest ( stack , PROJECT_DIR , UNUSABLE ) ) . toThrow ( SduiManifestRefusalError ) ;
377382 } ,
378383 ) ;
379384
380385 it ( 'absent with nothing to check: silence is the true answer' , ( ) => {
381- expect ( resolveJsxGateManifest ( NO_PAGES_STACK , ABSENT ) ) . toEqual ( { sduiManifest : undefined , notices : [ ] } ) ;
386+ expect ( resolveJsxGateManifest ( NO_PAGES_STACK , PROJECT_DIR , ABSENT ) ) . toEqual ( { sduiManifest : undefined , notices : [ ] } ) ;
382387 expect (
383- resolveJsxGateManifest ( { pages : [ { name : 'r' , kind : 'react' , source : 'export default () => null' } ] } , ABSENT ) ,
388+ resolveJsxGateManifest (
389+ { pages : [ { name : 'r' , kind : 'react' , source : 'export default () => null' } ] } ,
390+ PROJECT_DIR ,
391+ ABSENT ,
392+ ) ,
384393 ) . toEqual ( { sduiManifest : undefined , notices : [ ] } ) ;
385394 } ) ;
386395
387396 it ( 'unusable with nothing to check: not read by anything, so not refused' , ( ) => {
388- expect ( resolveJsxGateManifest ( NO_PAGES_STACK , UNUSABLE ) ) . toEqual ( { sduiManifest : undefined , notices : [ ] } ) ;
397+ expect ( resolveJsxGateManifest ( NO_PAGES_STACK , PROJECT_DIR , UNUSABLE ) ) . toEqual ( { sduiManifest : undefined , notices : [ ] } ) ;
389398 expect ( errSpy ) . not . toHaveBeenCalled ( ) ;
390399 } ) ;
391400
392401 it ( 'unusable with a page to check: refused, reported once on stderr, no minted code' , ( ) => {
393402 let thrown : unknown ;
394403 try {
395- resolveJsxGateManifest ( HTML_STACK , UNUSABLE ) ;
404+ resolveJsxGateManifest ( HTML_STACK , PROJECT_DIR , UNUSABLE ) ;
396405 } catch ( e ) {
397406 thrown = e ;
398407 }
@@ -409,13 +418,105 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
409418 } ) ;
410419} ) ;
411420
421+ /**
422+ * [#20166] The project leg is read in the project directory the command hands
423+ * over — the config's own — and never in the invoker's working directory. The
424+ * invoker's directory is played by a `process.cwd()` spy, so the pin is
425+ * hermetic: a foreign directory that carries its OWN manifest is where a
426+ * working-directory reading would land, and it must not win.
427+ */
428+ describe ( 'resolveJsxGateManifest — the project directory is the config’s, never the invoker’s cwd' , ( ) => {
429+ const FOREIGN_MANIFEST = { components : { span : { type : 'span' , inputs : [ { name : 'children' , type : 'slot' } ] } } } ;
430+ let root = '' ;
431+ let project = '' ;
432+ let foreign = '' ;
433+ let cwdSpy : ReturnType < typeof vi . spyOn > ;
434+ beforeEach ( ( ) => {
435+ root = realpathSync ( mkdtempSync ( join ( tmpdir ( ) , 'os-sdui-project-dir-' ) ) ) ;
436+ project = join ( root , 'project' ) ;
437+ foreign = join ( root , 'foreign' ) ;
438+ mkdirSync ( project ) ;
439+ mkdirSync ( foreign ) ;
440+ writeFileSync ( join ( foreign , PROJECT_SDUI_MANIFEST_FILE ) , JSON . stringify ( FOREIGN_MANIFEST ) ) ;
441+ cwdSpy = vi . spyOn ( process , 'cwd' ) ;
442+ } ) ;
443+ afterEach ( ( ) => {
444+ cwdSpy . mockRestore ( ) ;
445+ rmSync ( root , { recursive : true , force : true } ) ;
446+ } ) ;
447+
448+ it ( 'lit control: standing in the foreign directory, the working-directory default reads ITS manifest' , ( ) => {
449+ cwdSpy . mockReturnValue ( foreign ) ;
450+ expect ( resolveSduiManifest ( ) ) . toEqual ( {
451+ status : 'resolved' ,
452+ manifest : FOREIGN_MANIFEST ,
453+ path : join ( foreign , PROJECT_SDUI_MANIFEST_FILE ) ,
454+ } ) ;
455+ } ) ;
456+
457+ it ( 'a foreign cwd carrying its own manifest does not win: the manifest beside the config is read' , ( ) => {
458+ writeFileSync ( join ( project , PROJECT_SDUI_MANIFEST_FILE ) , JSON . stringify ( MANIFEST ) ) ;
459+ cwdSpy . mockReturnValue ( foreign ) ;
460+ expect ( resolveJsxGateManifest ( HTML_STACK , project ) ) . toEqual ( { sduiManifest : MANIFEST , notices : [ ] } ) ;
461+ } ) ;
462+
463+ it ( 'control: standing in the project directory itself, the same answer' , ( ) => {
464+ writeFileSync ( join ( project , PROJECT_SDUI_MANIFEST_FILE ) , JSON . stringify ( MANIFEST ) ) ;
465+ cwdSpy . mockReturnValue ( project ) ;
466+ expect ( resolveJsxGateManifest ( HTML_STACK , project ) ) . toEqual ( { sduiManifest : MANIFEST , notices : [ ] } ) ;
467+ } ) ;
468+
469+ it ( 'a project with no manifest of its own does not borrow the foreign one' , ( ) => {
470+ cwdSpy . mockReturnValue ( foreign ) ;
471+ const r = resolveJsxGateManifest ( HTML_STACK , project ) ;
472+ // Whatever the console leg answers in this checkout, it is never the
473+ // foreign file, and a notice (where one is due) names the project's path.
474+ expect ( r . sduiManifest ) . not . toEqual ( FOREIGN_MANIFEST ) ;
475+ for ( const n of r . notices ) {
476+ expect ( n . message ) . toContain ( join ( project , PROJECT_SDUI_MANIFEST_FILE ) ) ;
477+ expect ( n . message ) . not . toContain ( foreign ) ;
478+ }
479+ } ) ;
480+
481+ it ( 'a malformed manifest in the foreign cwd refuses nothing: it is not the project’s' , ( ) => {
482+ writeFileSync ( join ( project , PROJECT_SDUI_MANIFEST_FILE ) , JSON . stringify ( MANIFEST ) ) ;
483+ writeFileSync ( join ( foreign , PROJECT_SDUI_MANIFEST_FILE ) , '{ "components": [ oops' ) ;
484+ cwdSpy . mockReturnValue ( foreign ) ;
485+ expect ( resolveJsxGateManifest ( HTML_STACK , project ) ) . toEqual ( { sduiManifest : MANIFEST , notices : [ ] } ) ;
486+ } ) ;
487+ } ) ;
488+
489+ /**
490+ * [#20166] The seam the pins above cannot reach: each of the three authoring
491+ * commands hands the gate the directory of the config `loadConfig` resolved.
492+ * The command-level behaviour — an explicit config path run from a foreign
493+ * directory — is pinned by `test/jsx-gate-manifest-notice.e2e.test.ts`, which
494+ * runs NIGHTLY; this is its per-PR half.
495+ */
496+ describe ( 'the three authoring commands hand the gate the config’s directory' , ( ) => {
497+ const COMMANDS_DIR = join ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' , 'commands' ) ;
498+ const CALL = / r e s o l v e J s x G a t e M a n i f e s t \( / g;
499+ // One call, bounded by its `;`: the stack, then `dirname(absolutePath)`.
500+ const CONFIG_DIR_CALL = / r e s o l v e J s x G a t e M a n i f e s t \( [ ^ ; ] * ?, \s * (?: p a t h \. ) ? d i r n a m e \( a b s o l u t e P a t h \) \s * \) ; / g;
501+
502+ it . each ( [ 'validate.ts' , 'compile.ts' , 'lint.ts' ] ) ( '%s' , ( file ) => {
503+ const source = readFileSync ( join ( COMMANDS_DIR , file ) , 'utf8' ) ;
504+ // `absolutePath` is the path `loadConfig` resolved for this run.
505+ expect ( source ) . toMatch ( / c o n s t \{ [ ^ } ] * \b a b s o l u t e P a t h \b [ ^ } ] * \} = l o a d e d ; / ) ;
506+ expect ( source ) . toMatch ( / c o n s t l o a d e d = a w a i t l o a d C o n f i g \( / ) ;
507+ const calls = source . match ( CALL ) ?? [ ] ;
508+ expect ( calls ) . toHaveLength ( 1 ) ;
509+ expect ( source . match ( CONFIG_DIR_CALL ) ?? [ ] ) . toHaveLength ( calls . length ) ;
510+ } ) ;
511+ } ) ;
512+
412513describe ( 'printJsxGateNotices — the text face of `os validate` / `os build`' , ( ) => {
413514 it ( 'prints the rule tag and the hint, and nothing for an empty list' , ( ) => {
414515 const log = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } ) ;
415516 try {
416517 printJsxGateNotices ( [ ] ) ;
417518 expect ( log ) . not . toHaveBeenCalled ( ) ;
418- printJsxGateNotices ( resolveJsxGateManifest ( HTML_STACK , ABSENT ) . notices ) ;
519+ printJsxGateNotices ( resolveJsxGateManifest ( HTML_STACK , PROJECT_DIR , ABSENT ) . notices ) ;
419520 const out = log . mock . calls . map ( ( c ) => String ( c [ 0 ] ) ) . join ( '\n' ) ;
420521 expect ( out ) . toContain ( `[${ JSX_PARSE_LEVEL_ONLY_RULE } ]` ) ;
421522 expect ( out ) . toContain ( '/proj/sdui.manifest.json' ) ;
0 commit comments