@@ -783,8 +783,14 @@ describe('lintLivenessProperties', () => {
783783// assertions say nothing about which properties the ledger warns on — that
784784// stays the job of every other block in this file.
785785describe ( 'the array fan-out, against a synthetic warn map (#10262)' , ( ) => {
786+ // #11384: `status: 'dead'` is explicit on purpose. Before that fix, `describe()`
787+ // graded any non-`experimental` entry `dead` by fallthrough, so a synthetic
788+ // entry with no `status` at all worked here by accident; now an entry that
789+ // does not name a recognised status throws (the loud boundary the card asked
790+ // for), so this walker-only fixture must declare one — `dead` is arbitrary,
791+ // this block asserts nothing about verdicts, only about the fan-out.
786792 const warnOn = ( ...paths : string [ ] ) =>
787- new Map ( paths . map ( ( p ) => [ p , { authorWarn : true , authorHint : 'synthetic (#10262)' } ] as const ) ) ;
793+ new Map ( paths . map ( ( p ) => [ p , { status : 'dead' , authorWarn : true , authorHint : 'synthetic (#10262)' } ] as const ) ) ;
788794
789795 /** `n` navigation entries; those at `authored` set the warned key. */
790796 const navItems = ( n : number , authored : number [ ] ) =>
@@ -860,3 +866,136 @@ describe('the array fan-out, against a synthetic warn map (#10262)', () => {
860866 } ) ;
861867 } ) ;
862868} ) ;
869+
870+ // ── #11384: `describe()` gives `dead` / `experimental` / `planned` DISTINCT
871+ // verdicts — own rule id, own message, own default hint — and refuses to guess
872+ // on a status it does not recognise instead of silently grading it `dead`.
873+ //
874+ // The bug: a `planned` row (declared, and a consumer is being built against it
875+ // — the OPPOSITE of `dead`) fell through the old two-branch `describe()` into
876+ // the `dead` branch, so the finding's MESSAGE told the author to remove
877+ // something the ledger's own `authorHint`/`note` on the SAME finding said to
878+ // keep. `field.relatedListFilter`, `object.externalSharingModel` and
879+ // `translation.flows` are the three shipped rows this hit.
880+ //
881+ // The real ledgers currently have PLANNED rows and EXPERIMENTAL rows, but — as
882+ // this file's other comments document at length (#2377, #3896, #4509) — no
883+ // `dead`+`authorWarn` row survives in tree; every one that existed was retired
884+ // via enforce-or-remove rather than kept around to warn about. So the `dead`
885+ // branch, the `live`-mistakenly-warned case, and the unrecognised-status throw
886+ // are pinned here against SYNTHETIC entries through the `checkItemAgainstWarnMap`
887+ // seam (#10262) — exactly the kind of verdict-level testing that seam exists
888+ // for; the PLANNED branch is pinned against BOTH the real ledgers (so it stays
889+ // a contract test) and a synthetic no-hint entry (to pin the DEFAULT wording).
890+ describe ( 'dead / experimental / planned verdicts are distinct, and unknown statuses fail loud (#11384)' , ( ) => {
891+ const oneEntry = ( entry : Record < string , unknown > ) => new Map ( [ [ 'gizmo' , entry ] ] ) ;
892+
893+ // ── REAL LEDGER: the three rows the card captured ──────────────────────
894+ it ( 'REAL LEDGER: translation.flows (planned) — planned rule id, non-contradictory message, hint preserved' , ( ) => {
895+ const findings = lintLivenessProperties ( {
896+ translations : [ {
897+ 'zh-CN' : { flows : { lead_conversion : { screens : { screen_1 : { title : '转化详情' } } } } } ,
898+ } ] ,
899+ } ) ;
900+ expect ( findings ) . toHaveLength ( 1 ) ;
901+ const [ f ] = findings ;
902+ expect ( f . rule ) . toBe ( 'liveness-planned-property' ) ;
903+ expect ( f . message ) . not . toContain ( 'dead' ) ;
904+ expect ( f . message ) . toContain ( 'is planned' ) ;
905+ // The card's own captured hint — unchanged by this fix, just no longer
906+ // contradicted by the message sitting next to it.
907+ expect ( f . hint ) . toContain ( 'screen-flow runner' ) ;
908+ } ) ;
909+
910+ it ( 'REAL LEDGER: field.relatedListFilter (planned) — planned rule id, non-contradictory message' , ( ) => {
911+ const findings = lintLivenessProperties ( {
912+ objects : [ {
913+ name : 'account' ,
914+ fields : [ { name : 'related_orders' , type : 'text' , relatedListFilter : { field : 'account_id' } } ] ,
915+ } ] ,
916+ } ) ;
917+ const f = findings . find ( ( x ) => x . message . includes ( 'relatedListFilter' ) ) ;
918+ expect ( f ) . toBeDefined ( ) ;
919+ expect ( f ! . rule ) . toBe ( 'liveness-planned-property' ) ;
920+ expect ( f ! . message ) . not . toContain ( 'dead' ) ;
921+ } ) ;
922+
923+ it ( 'REAL LEDGER: object.externalSharingModel (planned, no authorHint — falls back to `note`) — planned rule id, note hint does not say Remove it' , ( ) => {
924+ const findings = lintLivenessProperties ( { objects : [ { name : 'widget' , externalSharingModel : 'read' } ] } ) ;
925+ const f = findings . find ( ( x ) => x . message . includes ( 'externalSharingModel' ) ) ;
926+ expect ( f ) . toBeDefined ( ) ;
927+ expect ( f ! . rule ) . toBe ( 'liveness-planned-property' ) ;
928+ expect ( f ! . message ) . not . toContain ( 'dead' ) ;
929+ expect ( f ! . hint ) . not . toMatch ( / ^ R e m o v e i t / ) ;
930+ } ) ;
931+
932+ // ── SYNTHETIC: the default hint per verdict, when neither authorHint nor
933+ // note is present — the shape #11384 explicitly called out ("the default
934+ // hint for a planned row without an authorHint must NOT say 'Remove it'") ──
935+ it ( 'SYNTHETIC: a planned entry with no authorHint/note gets the planned DEFAULT hint, never "Remove it"' , ( ) => {
936+ const findings = checkItemAgainstWarnMap (
937+ 'gadget' ,
938+ { name : 'g1' , gizmo : 'x' } ,
939+ "gadget 'g1'" ,
940+ oneEntry ( { status : 'planned' , authorWarn : true } ) ,
941+ ) ;
942+ expect ( findings ) . toHaveLength ( 1 ) ;
943+ expect ( findings [ 0 ] . rule ) . toBe ( 'liveness-planned-property' ) ;
944+ expect ( findings [ 0 ] . message ) . not . toContain ( 'dead' ) ;
945+ expect ( findings [ 0 ] . hint ) . not . toContain ( 'Remove it' ) ;
946+ expect ( findings [ 0 ] . hint . toLowerCase ( ) ) . toContain ( 'keep it' ) ;
947+ } ) ;
948+
949+ it ( 'SYNTHETIC: a dead entry with no authorHint/note keeps the dead rule id, "liveness: dead" message and the "Remove it" default hint' , ( ) => {
950+ const findings = checkItemAgainstWarnMap (
951+ 'gadget' ,
952+ { name : 'g1' , gizmo : 'x' } ,
953+ "gadget 'g1'" ,
954+ oneEntry ( { status : 'dead' , authorWarn : true } ) ,
955+ ) ;
956+ expect ( findings ) . toHaveLength ( 1 ) ;
957+ expect ( findings [ 0 ] . rule ) . toBe ( 'liveness-dead-property' ) ;
958+ expect ( findings [ 0 ] . message ) . toContain ( 'liveness: dead' ) ;
959+ expect ( findings [ 0 ] . hint ) . toBe ( 'Remove it — it is declared in the spec but not consumed at runtime.' ) ;
960+ } ) ;
961+
962+ it ( 'SYNTHETIC: an experimental entry with no authorHint/note gets an experimental default hint, never "Remove it"' , ( ) => {
963+ const findings = checkItemAgainstWarnMap (
964+ 'gadget' ,
965+ { name : 'g1' , gizmo : 'x' } ,
966+ "gadget 'g1'" ,
967+ oneEntry ( { status : 'experimental' } ) ,
968+ ) ;
969+ expect ( findings ) . toHaveLength ( 1 ) ;
970+ expect ( findings [ 0 ] . rule ) . toBe ( 'liveness-experimental-property' ) ;
971+ expect ( findings [ 0 ] . hint ) . not . toContain ( 'Remove it' ) ;
972+ } ) ;
973+
974+ // ── SYNTHETIC: the unknown-status boundary — loud, never silently `dead` ──
975+ it ( 'SYNTHETIC: an unrecognised status fails LOUD, naming the status, instead of silently grading as dead' , ( ) => {
976+ expect ( ( ) =>
977+ checkItemAgainstWarnMap (
978+ 'gadget' ,
979+ { name : 'g1' , gizmo : 'x' } ,
980+ "gadget 'g1'" ,
981+ oneEntry ( { status : 'quantum' , authorWarn : true } ) ,
982+ ) ,
983+ ) . toThrow ( / q u a n t u m / ) ;
984+ } ) ;
985+
986+ it ( 'SYNTHETIC: a `live` row mistakenly marked authorWarn also fails LOUD rather than being graded dead' , ( ) => {
987+ // Not a real shipped scenario (a `live` property should never carry
988+ // `authorWarn: true`) — but exactly the class of ledger-authoring mistake
989+ // the old silent fallthrough would have hidden by mislabelling it `dead`
990+ // too, which is why the boundary in `describe()` is status-based rather
991+ // than an `else if (status === 'planned') … else /* assume dead */`.
992+ expect ( ( ) =>
993+ checkItemAgainstWarnMap (
994+ 'gadget' ,
995+ { name : 'g1' , gizmo : 'x' } ,
996+ "gadget 'g1'" ,
997+ oneEntry ( { status : 'live' , authorWarn : true } ) ,
998+ ) ,
999+ ) . toThrow ( / l i v e / ) ;
1000+ } ) ;
1001+ } ) ;
0 commit comments