@nodaro/prompts 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/index.cjs +719 -255
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +390 -2
  4. package/dist/index.d.ts +390 -2
  5. package/dist/index.js +689 -257
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +110 -0
  9. package/src/__tests__/age-floor.test.ts +347 -0
  10. package/src/__tests__/catalog-id-guard.test.ts +146 -0
  11. package/src/__tests__/catalog-overlay.test.ts +163 -0
  12. package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
  13. package/src/__tests__/fixtures/parameter-hint-golden.json +2 -2
  14. package/src/__tests__/minor-age-floor.test.ts +136 -0
  15. package/src/__tests__/multi-picker-spec.test.ts +21 -1
  16. package/src/__tests__/person-pack-adult-only.test.ts +45 -0
  17. package/src/__tests__/person-regional-aesthetic.test.ts +2 -1
  18. package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
  19. package/src/__tests__/provider-prompt-doctrine.test.ts +39 -0
  20. package/src/action-fx.ts +2 -1
  21. package/src/aesthetic.ts +2 -1
  22. package/src/age-floor.ts +296 -0
  23. package/src/age-signal.ts +36 -0
  24. package/src/atmosphere.ts +2 -1
  25. package/src/backdrop.ts +2 -1
  26. package/src/camera-format.ts +2 -1
  27. package/src/camera-motions.ts +2 -1
  28. package/src/catalog-id-guard.ts +189 -0
  29. package/src/catalog-overlay.ts +148 -0
  30. package/src/catalog-packs.ts +27 -2
  31. package/src/character-fx.ts +2 -1
  32. package/src/color-look.ts +2 -1
  33. package/src/composition-effects.ts +2 -1
  34. package/src/era.ts +2 -1
  35. package/src/exposure-settings.ts +2 -1
  36. package/src/framing.ts +2 -1
  37. package/src/gemini-omni-inputs.ts +11 -3
  38. package/src/held-prop.ts +3 -1
  39. package/src/index.ts +4 -0
  40. package/src/instrumentation.ts +5 -4
  41. package/src/lens.ts +2 -1
  42. package/src/lighting.ts +2 -1
  43. package/src/loop-subject.ts +3 -1
  44. package/src/materials.ts +2 -1
  45. package/src/mood.ts +16 -5
  46. package/src/music-genre.ts +5 -4
  47. package/src/music-mood.ts +4 -3
  48. package/src/parameter-prompt-hint.ts +13 -57
  49. package/src/person-packs.ts +14 -2
  50. package/src/person.ts +75 -38
  51. package/src/photo-genre.ts +14 -3
  52. package/src/photographer.ts +2 -1
  53. package/src/picker-analyzer-registry.ts +46 -0
  54. package/src/picker-catalogs.ts +11 -0
  55. package/src/pose.ts +17 -6
  56. package/src/post-process-effects.ts +2 -1
  57. package/src/prompt-builder.ts +3 -3
  58. package/src/prompt-wizard-categories.ts +6 -0
  59. package/src/provider-prompt-doctrine.ts +51 -2
  60. package/src/render-quality.ts +2 -1
  61. package/src/setting.ts +3 -1
  62. package/src/shared-catalog-overlay.ts +83 -0
  63. package/src/style.ts +17 -1
  64. package/src/styling.ts +62 -30
  65. package/src/subject-registry.ts +2 -2
  66. package/src/temporal.ts +2 -1
  67. package/src/transitions.ts +2 -1
  68. package/src/voice-character.ts +6 -5
  69. package/src/voice-delivery.ts +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^2.18.0"
23
+ "@nodaro/shared": "^2.20.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PEOPLE } from "../person.js"
3
+ import { STYLINGS } from "../styling.js"
4
+ import { MOODS } from "../mood.js"
5
+ import { POSES } from "../pose.js"
6
+ import { PHOTO_GENRES } from "../photo-genre.js"
7
+ import { getAdultOnlyEntries } from "../age-floor.js"
8
+
9
+ /**
10
+ * A RATCHET, not a deriver (audit 2026-09-01 B1): the regex over-matches
11
+ * ("nun … rosary at the waist", "construction worker … tool belt at the
12
+ * waist", "karate fighter … barefoot") and under-matches real entries
13
+ * ("state-fitted", "legwear-fishnets"). So: every entry the regex flags must
14
+ * either carry `adultOnly` or be named in EXEMPT with a reason; and the
15
+ * curated set must never shrink below CURATED. Adding a risky entry without
16
+ * the flag fails here; adding a harmless entry that trips the regex is a
17
+ * one-line exemption.
18
+ */
19
+ const RISKY =
20
+ /\bbust\b|\bhips\b|midriff|navel|bare shoulders|shoulders uncovered|\bsheer\b|lingerie|bikini|swimsuit|wet skin|glisten|clinging|plunging|lips (?:slightly )?(?:parted|open)|biting the lower lip|\bpout|half-lidded|lowered lids|seduct|sultry|smolder|voluptuous|tan lines|d[ée]colletage|body chain|near-naked|hugging the contours|close-fitting|latex|fishnet|thigh-high|stockings|corset|bra-style|mesh top|eroticism/i
21
+
22
+ /** Entries the regex trips on that are NOT body-exposure content. */
23
+ const EXEMPT: Readonly<Record<string, string>> = {
24
+ "nun": "rosary at the waist — garment placement",
25
+ "construction-worker": "tool belt at the waist",
26
+ "karate-fighter": "barefoot in a fighting stance — martial arts",
27
+ "mermaid": "from the waist up — creature framing",
28
+ "merman": "from the waist up — creature framing",
29
+ "outfit-bathrobe": "tied at the waist",
30
+ "outfit-pirate": "sash at the waist",
31
+ "outfit-chapan": "sash at the waist",
32
+ "bottom-mom-jeans": "high-waisted — garment cut",
33
+ "base-very-long-straight": "hair down to the waist",
34
+ "base-very-long-wavy": "hair down to the waist",
35
+ "base-very-long-curly": "hair down to the waist",
36
+ "lip-state-bare": "bare, untreated lips — no makeup",
37
+ "eye-state-glassy": "tear-glazed eyes — emotion",
38
+ "hips-balanced": "neutral default, empty hint",
39
+ "bust-average": "neutral default, empty hint",
40
+ "waist-average": "neutral default, empty hint",
41
+ "alice-wonderland": "knee-high stockings on a child-coded fairytale costume — not body exposure",
42
+ "hair-wet": "wet hair only — no clothing or body exposure implied",
43
+ "fabric-chiffon": "sheer fabric as a mainstream fashion material — not body exposure",
44
+ "top-crop-top": "cropped top ending above the midriff — mainstream fashion silhouette, not exposure-focused like bra/bikini tops",
45
+ "caught-off-guard": "lips parted from a startled reaction, not a seductive expression",
46
+ "hands-on-hips": "neutral confident stance — hips referenced as a body part, no exposure or sexual content",
47
+ "hand-position-hands-on-hips": "neutral confident stance, hand-position variant of hands-on-hips",
48
+ }
49
+
50
+ /** The hand-curated set. The test fails if any of these loses the flag. */
51
+ const CURATED = [
52
+ // person — body axes
53
+ "bust-small", "bust-full", "bust-very-full", "waist-defined", "waist-straight", "hips-wide", "hips-narrow",
54
+ "silhouette-hourglass", "silhouette-pear",
55
+ // person — lips / eyes / skin / features / types / vibes
56
+ "lip-state-glossy", "lip-state-parted", "lip-state-bitten", "lip-state-pouting",
57
+ "eye-state-half-lidded",
58
+ "texture-shower-fresh-wet", "texture-glistening", "texture-tan-lines", "texture-oily",
59
+ "feature-bare-shoulders", "feature-collarbone-visible", "feature-midriff-visible", "feature-navel-visible",
60
+ "feature-chest-tattoo", "feature-back-tattoo", "feature-leg-tattoo",
61
+ "femme-fatale", "thick", "vampire-woman", "feral-wildman", "buenos-aires-tango",
62
+ // styling
63
+ "top-bra-top", "top-bikini-top", "top-mesh-top", "top-sheer-top", "top-corset",
64
+ "state-fitted", "state-sheer", "state-wet", "state-cropped", "state-off-shoulder", "state-halter-neck", "state-plunging-neck",
65
+ "outfit-lingerie", "outfit-bikini", "outfit-one-piece-swim", "outfit-school-uniform",
66
+ "fabric-latex",
67
+ "legwear-sheer-tights", "legwear-fishnets", "legwear-thigh-highs", "legwear-lace-top-stockings",
68
+ "footwear-thigh-high-boots",
69
+ "jewelry-choker-chain", "jewelry-choker-leather", "jewelry-choker-velvet", "jewelry-body-chain",
70
+ // mood / pose / photo-genre
71
+ "seductive", "sultry", "smoldering", "flirty",
72
+ "biting-lip", "arched-back", "sitting-edge-of-bed", "lounging", "lying-down",
73
+ "brand-helmut-newton", "glamour-portrait",
74
+ ] as const
75
+
76
+ const ALL = [...PEOPLE, ...STYLINGS, ...MOODS, ...POSES, ...PHOTO_GENRES]
77
+
78
+ describe("adultOnly ratchet", () => {
79
+ it("every id in CURATED exists and carries the flag", () => {
80
+ const byId = new Map(ALL.map((e) => [e.id, e]))
81
+ const missing = CURATED.filter((id) => !byId.has(id))
82
+ expect(missing, "curated ids that do not exist").toEqual([])
83
+ const unflagged = CURATED.filter((id) => byId.get(id)!.adultOnly !== true)
84
+ expect(unflagged, "curated ids missing adultOnly").toEqual([])
85
+ })
86
+
87
+ it("every entry the RISKY regex trips on is flagged or exempted with a reason", () => {
88
+ const offenders = ALL.filter(
89
+ (e) => RISKY.test(`${e.promptHint} ${e.term ?? ""}`) && e.adultOnly !== true && !(e.id in EXEMPT),
90
+ ).map((e) => `${e.id}: ${e.promptHint}`)
91
+ expect(offenders, "flag these or add them to EXEMPT with a reason").toEqual([])
92
+ })
93
+
94
+ it("no exemption is stale (an exempt id that is now flagged, or gone)", () => {
95
+ const byId = new Map(ALL.map((e) => [e.id, e]))
96
+ for (const id of Object.keys(EXEMPT)) {
97
+ expect(byId.has(id), `EXEMPT names a missing id ${id}`).toBe(true)
98
+ expect(byId.get(id)!.adultOnly, `EXEMPT ${id} is now flagged — drop the exemption`).toBeUndefined()
99
+ }
100
+ })
101
+
102
+ it("neutral defaults with an empty hint are never flagged", () => {
103
+ for (const e of ALL) if (e.promptHint === "") expect(e.adultOnly, e.id).toBeUndefined()
104
+ })
105
+
106
+ it("getAdultOnlyEntries covers exactly the flagged set", () => {
107
+ const flagged = ALL.filter((e) => e.adultOnly === true).map((e) => e.id).sort()
108
+ expect(getAdultOnlyEntries().map((e) => e.id).sort()).toEqual(flagged)
109
+ })
110
+ })
@@ -0,0 +1,347 @@
1
+ import { describe, it, expect, afterEach } from "vitest"
2
+ import { isMinorAge, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS, getAdultOnlyIds, getMinorAgeHintStrings, containsMinorAgeHint, buildNeedleAlternationSource } from "../age-floor.js"
3
+ import { getRegisteredPeople, resetPersonPacks } from "../person-packs.js"
4
+ import { PEOPLE } from "../person.js"
5
+ import { MOODS } from "../mood.js"
6
+ import { POSES } from "../pose.js"
7
+ import { PHOTO_GENRES } from "../photo-genre.js"
8
+ import { STYLINGS } from "../styling.js"
9
+ import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
10
+
11
+ describe("isMinorAge — an ADULT allow-list, never a minor deny-list", () => {
12
+ it("every catalog age id outside ADULT_AGE_IDS is inside the floor (a new age id defaults IN)", () => {
13
+ const ageIds = PEOPLE.filter((p) => p.dimension === "age" && p.id !== "age-custom").map((p) => p.id)
14
+ for (const id of ageIds) {
15
+ expect(isMinorAge({ age: id }), id).toBe(!ADULT_AGE_IDS.has(id))
16
+ }
17
+ // The teen buckets are all inside, including the 16-19 one (decision 2).
18
+ for (const id of ["age-baby", "age-toddler", "age-young-child", "age-child", "age-pre-teen", "age-early-teen", "age-late-teen", "age-teen"]) {
19
+ expect(isMinorAge({ age: id }), id).toBe(true)
20
+ }
21
+ for (const id of ADULT_AGE_IDS) expect(isMinorAge({ age: id }), id).toBe(false)
22
+ })
23
+
24
+ it("custom age: the boundary matches buildAgeFragment's 'in their teens' cut (< 20)", () => {
25
+ expect(isMinorAge({ age: "age-custom", customAge: 5 })).toBe(true)
26
+ expect(isMinorAge({ age: "age-custom", customAge: 19 })).toBe(true)
27
+ expect(isMinorAge({ age: "age-custom", customAge: 20 })).toBe(false)
28
+ expect(isMinorAge({ age: "age-custom", customAge: 47 })).toBe(false)
29
+ // age-custom with no number is unknown → floor.
30
+ expect(isMinorAge({ age: "age-custom" })).toBe(true)
31
+ })
32
+
33
+ it("no age at all: only a minor-implying TYPE puts the subject in the floor", () => {
34
+ expect(isMinorAge({})).toBe(false)
35
+ expect(isMinorAge(undefined)).toBe(false)
36
+ expect(isMinorAge({ type: "beautiful-woman" })).toBe(false)
37
+ for (const t of MINOR_IMPLYING_TYPE_IDS) expect(isMinorAge({ type: t }), t).toBe(true)
38
+ })
39
+
40
+ it("an adult age wins over a minor-implying type", () => {
41
+ expect(isMinorAge({ type: "peter-pan", age: "age-30s" })).toBe(false)
42
+ })
43
+
44
+ it("MINOR_IMPLYING_TYPE_IDS name real type entries", () => {
45
+ const typeIds = new Set(PEOPLE.filter((p) => p.dimension === "type").map((p) => p.id))
46
+ for (const t of MINOR_IMPLYING_TYPE_IDS) expect(typeIds.has(t), t).toBe(true)
47
+ })
48
+ })
49
+
50
+ describe("getAdultOnlyEntries reads catalogs through the pack-composed funnel", () => {
51
+ afterEach(() => resetCatalogPacks())
52
+
53
+ it("a pack-added mood option with adultOnly:true reaches the floor", () => {
54
+ registerCatalogPack({
55
+ id: "test/mood-adult",
56
+ catalogId: "mood",
57
+ mode: "extend",
58
+ options: [
59
+ {
60
+ id: "test-sultry-2",
61
+ label: "Sultry 2",
62
+ promptHint: "with a sultry expression",
63
+ term: "sultry",
64
+ category: MOODS[0].category,
65
+ adultOnly: true,
66
+ },
67
+ ],
68
+ })
69
+ expect(getAdultOnlyIds().has("test-sultry-2")).toBe(true)
70
+ })
71
+ })
72
+
73
+ describe("getAdultOnlyEntries propagates BASE-catalog flags too (not only pack-added ones)", () => {
74
+ // Guards the flattening path (`toOptions` / dimension builders in
75
+ // picker-catalogs.ts), which is separate from the pack-composition path the
76
+ // test above exercises: a base mood/pose/photo-genre/styling entry authored
77
+ // with `adultOnly: true` must survive being flattened into a `PickerOption`
78
+ // by `getRegisteredPickerCatalogs()`, or the floor silently loses it the
79
+ // moment content (not just packs) sets the flag. Currently vacuous (no base
80
+ // entry is flagged yet — Task 3 hand-curates them) but fails immediately if
81
+ // the flattening ever stops copying `adultOnly`.
82
+ it("every adultOnly:true id in the raw styling/mood/pose/photo-genre catalogs reaches getAdultOnlyIds()", () => {
83
+ const flaggedIds = [...STYLINGS, ...MOODS, ...POSES, ...PHOTO_GENRES]
84
+ .filter((e) => e.adultOnly === true)
85
+ .map((e) => e.id)
86
+ const ids = getAdultOnlyIds()
87
+ for (const id of flaggedIds) expect(ids.has(id), id).toBe(true)
88
+ })
89
+ })
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // W1-a fix round 1: the TEXT signal.
93
+ //
94
+ // `isMinorAge` reads the structured picker value — which the P0 arrival path
95
+ // does not have. person.nodaro.ai creates the character row with
96
+ // `{nodeId, name, projectId}` and never persists the `person` selection, so
97
+ // `row.person === null` and the only age evidence is the client-assembled
98
+ // prompt text itself. The client assembles that text FROM these very hints,
99
+ // so the catalog is the drift-proof needle list.
100
+ // ---------------------------------------------------------------------------
101
+ describe("getMinorAgeHintStrings — catalog-derived minor-age needles", () => {
102
+ afterEach(() => {
103
+ resetPersonPacks()
104
+ resetCatalogPacks()
105
+ })
106
+
107
+ const minorAgeIds = () =>
108
+ getRegisteredPeople()
109
+ .filter((p) => p.dimension === "age" && !ADULT_AGE_IDS.has(p.id) && p.promptHint.length >= 8)
110
+ .map((p) => p.id)
111
+
112
+ it("carries every minor age id's hint and no adult age id's hint", () => {
113
+ const hints = new Set(getMinorAgeHintStrings())
114
+ const byId = new Map(getRegisteredPeople().map((p) => [p.id, p]))
115
+ for (const id of minorAgeIds()) {
116
+ expect(hints.has(byId.get(id)!.promptHint.toLowerCase()), id).toBe(true)
117
+ }
118
+ for (const id of ADULT_AGE_IDS) {
119
+ const hint = byId.get(id)?.promptHint
120
+ if (hint) expect(hints.has(hint.toLowerCase()), id).toBe(false)
121
+ }
122
+ })
123
+
124
+ it("carries every minor-implying TYPE's hint", () => {
125
+ const hints = new Set(getMinorAgeHintStrings())
126
+ const byId = new Map(getRegisteredPeople().map((p) => [p.id, p]))
127
+ for (const t of MINOR_IMPLYING_TYPE_IDS) {
128
+ expect(hints.has(byId.get(t)!.promptHint.toLowerCase()), t).toBe(true)
129
+ }
130
+ })
131
+
132
+ it("never yields an empty or bare-word needle (age-custom's hint is \"\")", () => {
133
+ for (const h of getMinorAgeHintStrings()) {
134
+ expect(h.length).toBeGreaterThanOrEqual(8)
135
+ expect(h.trim()).toBe(h)
136
+ }
137
+ expect(getMinorAgeHintStrings()).not.toContain("")
138
+ expect(getMinorAgeHintStrings()).not.toContain("child")
139
+ expect(getMinorAgeHintStrings()).not.toContain("teen")
140
+ })
141
+
142
+ it("is longest-first (a shorter needle can be a substring of a longer one)", () => {
143
+ const lens = getMinorAgeHintStrings().map((s) => s.length)
144
+ expect([...lens].sort((a, b) => b - a)).toEqual(lens)
145
+ })
146
+
147
+ it("reads through the pack-composed funnel — a pack-added age entry is swept", () => {
148
+ // A deployment pack's new age id is NOT in ADULT_AGE_IDS, so it is inside
149
+ // the floor by default and its hint becomes a needle with no second list
150
+ // to update. This is the property a raw-PEOPLE reader would lose.
151
+ registerCatalogPack({
152
+ id: "test/person-minor-age",
153
+ catalogId: "person",
154
+ mode: "extend",
155
+ dimensions: [
156
+ {
157
+ dimension: "age",
158
+ field: "age",
159
+ label: "Age",
160
+ options: [
161
+ {
162
+ id: "age-space-cadet",
163
+ label: "Space cadet",
164
+ promptHint: "a cadet fresh out of the academy, barely sixteen",
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ } as never)
170
+ expect(getMinorAgeHintStrings()).toContain("a cadet fresh out of the academy, barely sixteen")
171
+ expect(containsMinorAgeHint("a cadet fresh out of the academy, barely sixteen, standing tall")).toBe(true)
172
+ })
173
+ })
174
+
175
+ describe("containsMinorAgeHint — the minor-age signal in free text", () => {
176
+ it("hits every minor age id's own hint; misses every adult one", () => {
177
+ const byId = new Map(getRegisteredPeople().map((p) => [p.id, p]))
178
+ for (const p of getRegisteredPeople()) {
179
+ if (p.dimension !== "age" || p.promptHint.length < 8) continue
180
+ const expected = !ADULT_AGE_IDS.has(p.id)
181
+ expect(containsMinorAgeHint(`a portrait of someone ${p.promptHint}, smiling`), p.id).toBe(expected)
182
+ }
183
+ // The two adult hints that CARRY a number are the closest thing to the
184
+ // boundary — "around 21 years old" / "around 27 years old" must not fire.
185
+ expect(containsMinorAgeHint(byId.get("age-early-20s")!.promptHint)).toBe(false)
186
+ expect(containsMinorAgeHint(byId.get("age-late-20s")!.promptHint)).toBe(false)
187
+ expect(containsMinorAgeHint("elderly, in their 70s or older")).toBe(false)
188
+ })
189
+
190
+ it("hits every minor-implying type's hint", () => {
191
+ const byId = new Map(getRegisteredPeople().map((p) => [p.id, p]))
192
+ for (const t of MINOR_IMPLYING_TYPE_IDS) {
193
+ expect(containsMinorAgeHint(byId.get(t)!.promptHint), t).toBe(true)
194
+ }
195
+ })
196
+
197
+ it("numeric ages: under 20 hits, 20+ does not", () => {
198
+ expect(containsMinorAgeHint("a 16 year old")).toBe(true)
199
+ expect(containsMinorAgeHint("a 16 years old girl")).toBe(true)
200
+ expect(containsMinorAgeHint("a 5-year-old on a swing")).toBe(true)
201
+ expect(containsMinorAgeHint("around 1 year old")).toBe(true)
202
+ expect(containsMinorAgeHint("a toddler around 2-3 years old")).toBe(true)
203
+ expect(containsMinorAgeHint("19 years old, in their teens")).toBe(true)
204
+ expect(containsMinorAgeHint("a 25 year old")).toBe(false)
205
+ expect(containsMinorAgeHint("a 20 year old")).toBe(false)
206
+ expect(containsMinorAgeHint("34 years old")).toBe(false)
207
+ // "old"/"older" must not glue: the trailing boundary is what stops it.
208
+ expect(containsMinorAgeHint("a 5 year older sibling")).toBe(false)
209
+ })
210
+
211
+ it("the custom-age teens shape hits", () => {
212
+ expect(containsMinorAgeHint("in their teens")).toBe(true)
213
+ expect(containsMinorAgeHint("17 years old, in their teens")).toBe(true)
214
+ })
215
+
216
+ // ───────────────────────────────────────────────────────────────────────
217
+ // Final review, item 2: the detector only knew the shapes `buildAgeFragment`
218
+ // itself emits. Everything a HUMAN or an LLM writes instead — "aged 12",
219
+ // "12yo", "12 y.o.", "12 yr old" — read as no age at all, which meant the
220
+ // WHOLE floor was absent for those prompts (no subjectMinor, no strip, no
221
+ // modest clause). Table-driven so a new shape is one row, and every shape is
222
+ // pinned on BOTH sides of the < 20 cut: a rule that fires on a minor must
223
+ // stay silent on the adult spelled the same way.
224
+ // ───────────────────────────────────────────────────────────────────────
225
+ describe("colloquial age shapes (table-driven, minor and adult)", () => {
226
+ const SHAPES: ReadonlyArray<{ label: string; minor: string; adult: string }> = [
227
+ { label: "N years old", minor: "a 12 years old at the fair", adult: "a 25 years old at the fair" },
228
+ { label: "N year old", minor: "a 12 year old at the fair", adult: "a 25 year old at the fair" },
229
+ { label: "N-year-old", minor: "a 12-year-old at the fair", adult: "a 25-year-old at the fair" },
230
+ { label: "aged N", minor: "a portrait, aged 12", adult: "a portrait, aged 45" },
231
+ { label: "age N", minor: "a portrait, age 12", adult: "a portrait, age 45" },
232
+ { label: "at the age of N", minor: "a portrait at the age of 12", adult: "a portrait at the age of 45" },
233
+ { label: "Nyo", minor: "a 12yo at the fair", adult: "a 25yo at the fair" },
234
+ { label: "N yo", minor: "a 12 yo at the fair", adult: "a 25 yo at the fair" },
235
+ { label: "N y.o.", minor: "a 12 y.o. at the fair", adult: "a 25 y.o. at the fair" },
236
+ { label: "N y/o", minor: "a 12 y/o at the fair", adult: "a 25 y/o at the fair" },
237
+ { label: "N yr old", minor: "a 12 yr old at the fair", adult: "a 25 yr old at the fair" },
238
+ { label: "N-yr-old", minor: "a 12-yr-old at the fair", adult: "a 25-yr-old at the fair" },
239
+ { label: "Nyrs old", minor: "a 12yrs old at the fair", adult: "a 25yrs old at the fair" },
240
+ { label: "N years of age", minor: "a 12 years of age subject", adult: "a 25 years of age subject" },
241
+ ]
242
+ for (const { label, minor, adult } of SHAPES) {
243
+ it(`"${label}" — the minor spelling hits`, () => expect(containsMinorAgeHint(minor), minor).toBe(true))
244
+ it(`"${label}" — the adult spelling does not`, () => expect(containsMinorAgeHint(adult), adult).toBe(false))
245
+ }
246
+
247
+ // The negatives that are NOT simply "the same shape with a bigger number":
248
+ // near-miss text that must never be read as an age at all.
249
+ it("non-age lookalikes stay silent", () => {
250
+ expect(containsMinorAgeHint("a 5 year older sibling")).toBe(false)
251
+ expect(containsMinorAgeHint("a woman in their 30s")).toBe(false)
252
+ expect(containsMinorAgeHint("elderly, in their 70s or older")).toBe(false)
253
+ // "age" glued to a preceding word character is not the word "age".
254
+ expect(containsMinorAgeHint("image 12 of the set")).toBe(false)
255
+ expect(containsMinorAgeHint("page 3 of the brief")).toBe(false)
256
+ // "yo" may not glue onto the next word.
257
+ expect(containsMinorAgeHint("12 young oak trees")).toBe(false)
258
+ expect(containsMinorAgeHint("a 12 yoga session")).toBe(false)
259
+ // The trailing boundary keeps ordinals out.
260
+ expect(containsMinorAgeHint("aged 12th in the rankings")).toBe(false)
261
+ })
262
+ })
263
+
264
+ // Final review, item 4: the needle alternation was literal, so a hint the
265
+ // writer re-spaced or hyphenated slipped past it.
266
+ it("a minor hint is matched however it was spaced or hyphenated", () => {
267
+ expect(containsMinorAgeHint("in their teens")).toBe(true)
268
+ expect(containsMinorAgeHint("in-their-teens")).toBe(true)
269
+ const hint = getMinorAgeHintStrings().find((h) => h.includes(" ")) as string
270
+ expect(containsMinorAgeHint(hint.replace(/ /g, " "))).toBe(true)
271
+ expect(containsMinorAgeHint(hint.replace(/ /g, "-"))).toBe(true)
272
+ })
273
+
274
+ it("does NOT fire on a bare mention of a child by an adult subject", () => {
275
+ expect(containsMinorAgeHint("a mother holding her child")).toBe(false)
276
+ expect(containsMinorAgeHint("a woman in her 30s reading to a child")).toBe(false)
277
+ expect(containsMinorAgeHint("a teacher, in their 40s, in a classroom of children")).toBe(false)
278
+ expect(containsMinorAgeHint("a childhood home in winter")).toBe(false)
279
+ })
280
+
281
+ it("empty / absent text is never a hit", () => {
282
+ expect(containsMinorAgeHint("")).toBe(false)
283
+ expect(containsMinorAgeHint(" ")).toBe(false)
284
+ expect(containsMinorAgeHint(null)).toBe(false)
285
+ expect(containsMinorAgeHint(undefined)).toBe(false)
286
+ })
287
+
288
+ it("THE INCIDENT STRING (2026-07-30, app_reports P0): client-assembled seedPrompt, no picker value", () => {
289
+ const incident =
290
+ "a young child around 5 years old, the clothing fitted and form-conscious, hugging the contours of the body, with lips slightly parted, taking a soft breath"
291
+ // Two independent rules catch it: the verbatim `age-young-child` hint, and
292
+ // the numeric shape "5 years old".
293
+ expect(containsMinorAgeHint(incident)).toBe(true)
294
+ // The same prompt about an adult subject must stay untouched.
295
+ const adultMirror = incident.replace("a young child around 5 years old", "a woman in her 30s")
296
+ expect(containsMinorAgeHint(adultMirror)).toBe(false)
297
+ })
298
+ })
299
+
300
+ // ───────────────────────────────────────────────────────────────────────────
301
+ // The shared needle builder — the single matcher definition BOTH layers use
302
+ // (Layer 1's `minorNeedleRegex`, Layer 2's backend strip alternation). Tested
303
+ // here directly so its two properties are pinned independently of whichever
304
+ // catalog phrases happen to be flagged today.
305
+ // ───────────────────────────────────────────────────────────────────────────
306
+ describe("buildNeedleAlternationSource", () => {
307
+ function match(needles: ReadonlyArray<string>, text: string): boolean {
308
+ const src = buildNeedleAlternationSource(needles)
309
+ return src !== null && new RegExp(src, "i").test(text)
310
+ }
311
+
312
+ it("tolerates any whitespace/hyphen spelling of the needle's separators", () => {
313
+ const needles = ["very full bust", "full bust"]
314
+ expect(match(needles, "very full bust")).toBe(true)
315
+ expect(match(needles, "very-full bust")).toBe(true)
316
+ expect(match(needles, "very full bust")).toBe(true)
317
+ expect(match(needles, "very\nfull\tbust")).toBe(true)
318
+ })
319
+
320
+ it("still requires AT LEAST one separator — it is not a substring rule", () => {
321
+ expect(match(["very full bust", "full bust"], "very fullbust")).toBe(false)
322
+ expect(match(["full bust"], "afullbust")).toBe(false)
323
+ })
324
+
325
+ it("keeps the word boundaries: a needle may not glue onto an adjacent word", () => {
326
+ expect(match(["mesh top"], "mesh tops")).toBe(false)
327
+ expect(match(["mesh top"], "a mesh top, and boots")).toBe(true)
328
+ expect(match(["narrow hips"], "narrow hipster jeans")).toBe(false)
329
+ })
330
+
331
+ it("escapes regex metacharacters in a needle", () => {
332
+ expect(match(["a (soft) breath."], "taking a (soft) breath. done")).toBe(true)
333
+ expect(match(["a.b"], "axb")).toBe(false)
334
+ })
335
+
336
+ it("returns null for an empty list rather than an everything-matches regex", () => {
337
+ expect(buildNeedleAlternationSource([])).toBeNull()
338
+ expect(buildNeedleAlternationSource(["", " "])).toBeNull()
339
+ })
340
+
341
+ it("preserves the caller's ordering (longest-first stays longest-first)", () => {
342
+ const src = buildNeedleAlternationSource(["very full bust", "full bust"]) as string
343
+ expect(src.indexOf("very")).toBeLessThan(src.lastIndexOf("full"))
344
+ const m = "a very full bust portrait".match(new RegExp(src, "i"))
345
+ expect(m?.[0]).toBe("very full bust")
346
+ })
347
+ })
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The wall. What it must do: refuse an id the deployment does not offer, on
3
+ * every shape a run can carry one in — a parameter node's own field, a pose
4
+ * sub-pick, a consumer node's folded `direction`/`subject`, a request body —
5
+ * and refuse NOTHING on a deployment with no packs.
6
+ */
7
+ import { afterEach, describe, expect, it } from "vitest"
8
+ import {
9
+ registerCatalogPack,
10
+ resetCatalogPacks,
11
+ findForeignCatalogIds,
12
+ findForeignCatalogIdsInBody,
13
+ foreignCatalogIdMessage,
14
+ catalogGuardActive,
15
+ getPickerCatalog,
16
+ SETTINGS,
17
+ POSES,
18
+ __resetCatalogIdGuardForTests,
19
+ } from "../index.js"
20
+
21
+ const setting0 = SETTINGS[0].id
22
+ const setting1 = SETTINGS[1].id
23
+ const pose0 = POSES[0].id
24
+
25
+ afterEach(() => {
26
+ resetCatalogPacks()
27
+ __resetCatalogIdGuardForTests()
28
+ })
29
+
30
+ describe("inert without packs", () => {
31
+ it("refuses nothing, even garbage — a deployment that curates nothing offers everything", () => {
32
+ expect(catalogGuardActive()).toBe(false)
33
+ expect(findForeignCatalogIds([{ id: "a", type: "setting", data: { setting: "not-a-real-id" } }])).toEqual([])
34
+ expect(findForeignCatalogIdsInBody("generate-image", { direction: { setting: "nope" } })).toEqual([])
35
+ })
36
+ })
37
+
38
+ describe("with a deny pack on `setting`", () => {
39
+ const deny = () => registerCatalogPack({ id: "g-deny", catalogId: "setting", mode: "deny", denyIds: [setting0] })
40
+
41
+ it("a parameter node carrying the denied id is named", () => {
42
+ deny()
43
+ const found = findForeignCatalogIds([{ id: "n1", type: "setting", data: { setting: setting0 } }])
44
+ expect(found).toEqual([{ nodeId: "n1", nodeType: "setting", field: "setting", id: setting0, catalogId: "setting" }])
45
+ })
46
+
47
+ it("a sibling id the pack left alone passes", () => {
48
+ deny()
49
+ expect(findForeignCatalogIds([{ id: "n1", type: "setting", data: { setting: setting1 } }])).toEqual([])
50
+ })
51
+
52
+ it("an id that never existed anywhere is refused too (not only denied ones)", () => {
53
+ deny()
54
+ expect(findForeignCatalogIds([{ id: "n1", type: "setting", data: { setting: "made-up" } }])).toHaveLength(1)
55
+ })
56
+
57
+ it("empty / absent values are not ids", () => {
58
+ deny()
59
+ expect(findForeignCatalogIds([{ id: "n1", type: "setting", data: { setting: "" } }])).toEqual([])
60
+ expect(findForeignCatalogIds([{ id: "n1", type: "setting", data: {} }])).toEqual([])
61
+ expect(findForeignCatalogIds([{ id: "n1", type: "setting", data: { setting: null } }])).toEqual([])
62
+ })
63
+
64
+ it("free-text fields on the same node are never validated", () => {
65
+ deny()
66
+ expect(
67
+ findForeignCatalogIds([{ id: "n1", type: "setting", data: { setting: setting1, preText: setting0, customText: "anything" } }]),
68
+ ).toEqual([])
69
+ })
70
+
71
+ it("a CONSUMER node's folded direction record is checked against the same catalog", () => {
72
+ deny()
73
+ const found = findForeignCatalogIds([{ id: "g1", type: "generate-image", data: { direction: { setting: setting0 } } }])
74
+ expect(found).toEqual([{ nodeId: "g1", nodeType: "generate-image", field: "setting", id: setting0, catalogId: "setting" }])
75
+ })
76
+
77
+ it("a request body's direction record is checked (the single-node routes)", () => {
78
+ deny()
79
+ expect(findForeignCatalogIdsInBody("generate-image", { direction: { setting: setting0 } })).toHaveLength(1)
80
+ expect(findForeignCatalogIdsInBody("generate-image", { direction: { setting: setting1 } })).toEqual([])
81
+ })
82
+
83
+ it("nodes of unrelated types are ignored", () => {
84
+ deny()
85
+ expect(findForeignCatalogIds([{ id: "t", type: "text-prompt", data: { text: setting0 } }])).toEqual([])
86
+ })
87
+ })
88
+
89
+ describe("the field contract covers the shapes the resolvers actually read", () => {
90
+ it("pose sub-picks (handPosition/bodyLean/headTilt/activity) resolve against `pose`", () => {
91
+ registerCatalogPack({ id: "g-pose", catalogId: "pose", mode: "deny", denyIds: [pose0] })
92
+ const found = findForeignCatalogIds([{ id: "p", type: "pose", data: { handPosition: pose0 } }])
93
+ expect(found.map((f) => f.field)).toEqual(["handPosition"])
94
+ })
95
+
96
+ it("array-valued multi-pick fields are checked per element", () => {
97
+ registerCatalogPack({ id: "g-pose2", catalogId: "pose", mode: "deny", denyIds: [pose0] })
98
+ // `pose` is single, but the guard's value reader must handle arrays for
99
+ // the multi catalogs; exercise it on a field that accepts both.
100
+ const found = findForeignCatalogIds([{ id: "p", type: "pose", data: { pose: [POSES[1].id, pose0] } }])
101
+ expect(found.map((f) => f.id)).toEqual([pose0])
102
+ })
103
+
104
+ it("a multi-dim catalog checks every dimension field", () => {
105
+ const framing = getPickerCatalog("framing")!
106
+ const dim = framing.dimensions![0]
107
+ const victim = dim.options[0].id
108
+ registerCatalogPack({ id: "g-framing", catalogId: "framing", mode: "deny", denyIds: [victim] })
109
+ const found = findForeignCatalogIds([{ id: "f", type: "framing", data: { [dim.field]: victim } }])
110
+ expect(found).toHaveLength(1)
111
+ expect(found[0].catalogId).toBe("framing")
112
+ })
113
+
114
+ it("legacy direction aliases map to their catalogs", () => {
115
+ const lens = getPickerCatalog("lens")!
116
+ const victim = lens.options![0].id
117
+ registerCatalogPack({ id: "g-lens", catalogId: "lens", mode: "deny", denyIds: [victim] })
118
+ expect(findForeignCatalogIdsInBody("generate-image", { direction: { lensId: victim } })).toHaveLength(1)
119
+ })
120
+
121
+ it("a field a REPLACE pack dropped entirely still counts as an id field — any value fails", () => {
122
+ const base = getPickerCatalog("framing")!
123
+ const dropped = base.dimensions![0]
124
+ registerCatalogPack({
125
+ id: "g-replace-drop",
126
+ catalogId: "framing",
127
+ mode: "replace",
128
+ catalog: { ...base, dimensions: base.dimensions!.slice(1), fields: base.fields!.slice(1) },
129
+ })
130
+ const found = findForeignCatalogIds([{ id: "f", type: "framing", data: { [dropped.field]: dropped.options[0].id } }])
131
+ expect(found).toHaveLength(1)
132
+ })
133
+ })
134
+
135
+ describe("the message", () => {
136
+ it("names every offending value once, by field", () => {
137
+ const msg = foreignCatalogIdMessage([
138
+ { nodeType: "setting", field: "setting", id: "x", catalogId: "setting" },
139
+ { nodeType: "setting", field: "setting", id: "x", catalogId: "setting" },
140
+ { nodeType: "pose", field: "pose", id: "y", catalogId: "pose" },
141
+ ])
142
+ expect(msg).toContain('setting="x"')
143
+ expect(msg).toContain('pose="y"')
144
+ expect(msg.split('setting="x"').length).toBe(2)
145
+ })
146
+ })