@nodaro/prompts 1.7.2 → 1.8.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 (76) hide show
  1. package/dist/index.cjs +5453 -4271
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1221 -63
  4. package/dist/index.d.ts +1221 -63
  5. package/dist/index.js +5366 -4273
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
  9. package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
  10. package/src/__tests__/catalog-packs.test.ts +130 -0
  11. package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
  12. package/src/__tests__/catalog-terms.test.ts +162 -0
  13. package/src/__tests__/character-default-role.test.ts +3 -2
  14. package/src/__tests__/content-free-contract.test.ts +45 -0
  15. package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
  16. package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
  17. package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
  18. package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
  19. package/src/__tests__/parameter-hint-mode.test.ts +385 -0
  20. package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
  21. package/src/__tests__/person-packs.test.ts +166 -0
  22. package/src/__tests__/project-all-catalogs.test.ts +26 -0
  23. package/src/__tests__/prompt-builder.test.ts +53 -0
  24. package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
  25. package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
  26. package/src/__tests__/term.test.ts +104 -0
  27. package/src/__tests__/transitions.test.ts +8 -5
  28. package/src/__tests__/upstream-immutability.test.ts +23 -0
  29. package/src/action-fx.ts +67 -17
  30. package/src/aesthetic.ts +54 -6
  31. package/src/atmosphere.ts +64 -23
  32. package/src/backdrop.ts +44 -30
  33. package/src/camera-format.ts +33 -11
  34. package/src/camera-motions.ts +89 -0
  35. package/src/catalog-packs.ts +125 -0
  36. package/src/catalog-sidecar-coverage.ts +36 -0
  37. package/src/character-fx.ts +112 -39
  38. package/src/color-look.ts +44 -27
  39. package/src/composition-effects.ts +21 -7
  40. package/src/era.ts +24 -0
  41. package/src/exposure-settings.ts +76 -18
  42. package/src/framing.ts +100 -0
  43. package/src/held-prop.ts +125 -63
  44. package/src/identity-lock.ts +12 -5
  45. package/src/image-reference-doctrine.ts +55 -0
  46. package/src/index.ts +6 -0
  47. package/src/instrumentation.ts +148 -57
  48. package/src/lens.ts +31 -15
  49. package/src/lighting.ts +120 -59
  50. package/src/loop-subject.ts +27 -1
  51. package/src/materials.ts +123 -69
  52. package/src/mood.ts +123 -51
  53. package/src/music-genre.ts +171 -67
  54. package/src/music-mood.ts +85 -17
  55. package/src/parameter-prompt-hint.ts +168 -56
  56. package/src/person-packs.ts +182 -0
  57. package/src/person.ts +506 -408
  58. package/src/photo-genre.ts +37 -22
  59. package/src/photographer.ts +138 -1
  60. package/src/picker-catalogs.ts +86 -40
  61. package/src/picker-wiring.ts +1 -1
  62. package/src/pose.ts +105 -40
  63. package/src/post-process-effects.ts +51 -8
  64. package/src/prompt-builder.ts +16 -1
  65. package/src/provider-prompt-doctrine.ts +1 -2
  66. package/src/render-quality.ts +25 -7
  67. package/src/setting.ts +30 -14
  68. package/src/style-presets.ts +1 -1
  69. package/src/style.ts +33 -15
  70. package/src/styling.ts +155 -94
  71. package/src/surround-fill.ts +67 -0
  72. package/src/temporal.ts +80 -18
  73. package/src/term.ts +155 -0
  74. package/src/transitions.ts +73 -26
  75. package/src/voice-character.ts +190 -90
  76. package/src/voice-delivery.ts +83 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.7.2",
3
+ "version": "1.8.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",
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { registerPersonPack, resetPersonPacks } from "../person-packs.js"
3
+ import { resetCatalogPacks, getRegisteredCatalogPacks } from "../catalog-packs.js"
4
+ import { getRegisteredPickerCatalogs, projectAllCatalogs } from "../picker-catalogs.js"
5
+ import { getParameterPromptHint } from "../parameter-prompt-hint.js"
6
+ import { computePackSidecarCoverage } from "../catalog-sidecar-coverage.js"
7
+ import { PERSON_SECTOR_PACK } from "./fixtures/person-sector-pack.js"
8
+
9
+ beforeEach(() => {
10
+ resetPersonPacks()
11
+ resetCatalogPacks()
12
+ })
13
+
14
+ describe("B7 person pack — one registration, every read kind", () => {
15
+ it("enumerates in the registered person catalog + /v1/catalogs projection", () => {
16
+ registerPersonPack(PERSON_SECTOR_PACK)
17
+ const person = getRegisteredPickerCatalogs().find((c) => c.catalogId === "person")!
18
+ expect(person.dimensions!.some((d) => d.field === "sectorAttire")).toBe(true)
19
+ const projected = projectAllCatalogs().find((c) => c.catalogId === "person")!
20
+ expect(projected.dimensions!.some((d) => d.options.some((o) => o.id === "attire-modest-suit"))).toBe(true)
21
+ })
22
+
23
+ it("composes a hint for the pack dimension", () => {
24
+ registerPersonPack(PERSON_SECTOR_PACK)
25
+ expect(
26
+ getParameterPromptHint({ id: "n1", type: "person", data: { sectorAttire: "attire-modest-suit" } }),
27
+ ).toContain("modest tailored suit")
28
+ })
29
+
30
+ it("reports the declared sidecar exemption (not a failure)", () => {
31
+ registerPersonPack(PERSON_SECTOR_PACK)
32
+ const pack = getRegisteredCatalogPacks().find((p) => p.id === PERSON_SECTOR_PACK.id)!
33
+ const cov = computePackSidecarCoverage(pack)
34
+ expect(cov.exempted.length).toBeGreaterThan(0) // e.g. hi/ja/ko/zh-CN/ru/ar declared exempt
35
+ expect(cov.missing.filter((m) => m.locale === "he")).toEqual([]) // he provided
36
+ })
37
+ })
@@ -0,0 +1,97 @@
1
+ // Ratchet: freeze the set of prompts/src files that read a RAW BASE catalog
2
+ // array (STYLES, MOODS, PEOPLE, …) directly instead of the pack-composed funnel
3
+ // (`getRegisteredPickerCatalogs()` / `getRegisteredPeople()`). A file that reads
4
+ // a raw array bypasses deployment-registered packs, so the seam only holds if
5
+ // that set can only SHRINK.
6
+ //
7
+ // The watched array names are DERIVED from `picker-catalogs.ts`'s own value
8
+ // imports — the ONE file that legitimately pulls every base array in to compose
9
+ // the funnel — so a brand-new catalog is watched automatically (no hand-list to
10
+ // drift). Substring/regex scan + a 4s ceiling (spec §3 finding 5: AST walks blew
11
+ // the CI budget).
12
+ import { describe, it, expect } from "vitest"
13
+ import { readFileSync, readdirSync, statSync } from "node:fs"
14
+ import { join, dirname, relative } from "node:path"
15
+ import { fileURLToPath } from "node:url"
16
+
17
+ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..")
18
+ const FUNNEL = join(SRC, "picker-catalogs.ts")
19
+
20
+ // Files allowed to read raw base arrays today, two kinds:
21
+ // • composition roots — the funnels the rest of the world reads FROM. These
22
+ // are NOT debt; they are where the base arrays legitimately enter.
23
+ // • tracked offenders — pending repoint (Phase-0 scope note). May only shrink.
24
+ // A stale entry (a listed file that no longer reads a raw array) fails too, so
25
+ // the list cannot rot: repointing a file means deleting its line here.
26
+ const ALLOWLIST = new Set<string>([
27
+ "picker-catalogs.ts", // composition root: the aggregate funnel composes every base array
28
+ "person-packs.ts", // composition root: getRegisteredPeople() composes packs onto raw PEOPLE
29
+ "picker-analyzer-registry.ts", // tracked offender: describe-to-picker analyzer, repoint pending
30
+ "picker-wiring.ts", // tracked offender: repoint pending
31
+ ])
32
+
33
+ describe("catalog funnel import ratchet", () => {
34
+ it("no NEW prompts file reads a raw base catalog array (funnel bypass)", () => {
35
+ const start = Date.now()
36
+ const watched = deriveWatchedArrays(readFileSync(FUNNEL, "utf8"))
37
+ // Guard the guard: if the derivation ever silently finds nothing (funnel
38
+ // moved/renamed), the whole ratchet would go vacuous — fail loudly instead.
39
+ expect(watched.size, "derivation found no base arrays — is picker-catalogs.ts still the funnel?").toBeGreaterThan(40)
40
+
41
+ const offenders = new Set<string>()
42
+ for (const f of walk(SRC)) {
43
+ if (!f.endsWith(".ts") || f.includes("__tests__")) continue
44
+ if (importsAnyOf(readFileSync(f, "utf8"), watched).length) offenders.add(relative(SRC, f))
45
+ }
46
+
47
+ const newOffenders = [...offenders].filter((f) => !ALLOWLIST.has(f)).sort()
48
+ expect(newOffenders, `New direct raw-array reader(s) — read the pack-composed funnel instead: ${newOffenders.join(", ")}`).toEqual([])
49
+
50
+ const stale = [...ALLOWLIST].filter((f) => !offenders.has(f)).sort()
51
+ expect(stale, `Stale ratchet allowlist entr(ies) — file no longer reads a raw array, delete the line: ${stale.join(", ")}`).toEqual([])
52
+
53
+ expect(Date.now() - start, "ratchet exceeded 4s ceiling").toBeLessThan(4000)
54
+ })
55
+ })
56
+
57
+ /**
58
+ * The raw base-array export names, DERIVED from the funnel's own value imports:
59
+ * uppercase identifiers (`STYLES`, `PEOPLE`, `ANIMALS`, …) minus the metadata
60
+ * siblings (`*_LABELS` / `*_ORDER` / `*_FIELD_BY_*`). `import type { … }` and
61
+ * `export … from` are ignored, so type re-exports and the defining modules never
62
+ * match.
63
+ */
64
+ function deriveWatchedArrays(funnelSource: string): Set<string> {
65
+ const watched = new Set<string>()
66
+ for (const name of importedNames(funnelSource)) {
67
+ if (/^[A-Z][A-Z0-9_]*$/.test(name) && !/_LABELS$|_ORDER$|_FIELD_BY_/.test(name)) watched.add(name)
68
+ }
69
+ return watched
70
+ }
71
+
72
+ /** Names this source VALUE-imports (skips `import type {…}` and inline `type` specifiers). */
73
+ function importedNames(source: string): string[] {
74
+ const names: string[] = []
75
+ const re = /import\s+(?:[\w*]+\s*,\s*)?\{([^}]*)\}\s*from\s*["'][^"']+["']/g
76
+ let m: RegExpExecArray | null
77
+ while ((m = re.exec(source))) {
78
+ for (let spec of m[1].split(",")) {
79
+ spec = spec.trim()
80
+ if (!spec || spec.startsWith("type ")) continue
81
+ names.push(spec.split(/\s+as\s+/)[0].trim())
82
+ }
83
+ }
84
+ return names
85
+ }
86
+
87
+ /** Watched names this source imports (the bypass signal). */
88
+ function importsAnyOf(source: string, watched: Set<string>): string[] {
89
+ return [...new Set(importedNames(source).filter((n) => watched.has(n)))]
90
+ }
91
+
92
+ function walk(dir: string): string[] {
93
+ return readdirSync(dir).flatMap((n) => {
94
+ const p = join(dir, n)
95
+ return statSync(p).isDirectory() ? walk(p) : [p]
96
+ })
97
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest"
2
+ import type { PickerCatalog, PickerOption } from "../picker-catalogs.js"
3
+ import type { PickerOptionInput, PickerCatalogInput } from "../catalog-packs.js"
4
+ import {
5
+ registerCatalogPack, getRegisteredCatalogPacks, resetCatalogPacks,
6
+ catalogPacksVersion, composePickerCatalogs,
7
+ type CatalogPack,
8
+ } from "../catalog-packs.js"
9
+ import { getParameterPromptHint } from "../parameter-prompt-hint.js"
10
+ import { getPickerCatalog } from "../picker-catalogs.js"
11
+
12
+ const base: readonly PickerCatalog[] = [
13
+ { nodeType: "setting", label: "Setting", catalogId: "setting", kind: "single", valueField: "setting",
14
+ options: [
15
+ { id: "forest", label: "Forest", promptHint: "in a forest", term: "forest" },
16
+ { id: "beach", label: "Beach", promptHint: "on a beach", term: "beach" },
17
+ ] },
18
+ ]
19
+
20
+ beforeEach(() => resetCatalogPacks())
21
+ afterEach(() => resetCatalogPacks()) // never leak a pack into another file's registry view
22
+
23
+ describe("composePickerCatalogs — pure", () => {
24
+ it("returns a structurally-equal NEW array when no packs (does not mutate base)", () => {
25
+ const out = composePickerCatalogs(base, [])
26
+ expect(out).toEqual(base)
27
+ expect(out).not.toBe(base)
28
+ expect(out[0]).not.toBe(base[0])
29
+ })
30
+
31
+ it("deny removes entry ids from the copy, leaving base untouched", () => {
32
+ const out = composePickerCatalogs(base, [{ id: "p", catalogId: "setting", mode: "deny", denyIds: ["beach"] }])
33
+ expect(out[0].options!.map((o: PickerOption) => o.id)).toEqual(["forest"])
34
+ expect(base[0].options!.map((o: PickerOption) => o.id)).toEqual(["forest", "beach"]) // base immutable
35
+ })
36
+
37
+ it("extend appends single-dim options", () => {
38
+ const out = composePickerCatalogs(base, [{ id: "p", catalogId: "setting", mode: "extend",
39
+ options: [{ id: "shul", label: "Shul", promptHint: "in a synagogue", term: "shul" }] }])
40
+ expect(out[0].options!.map((o: PickerOption) => o.id)).toEqual(["forest", "beach", "shul"])
41
+ })
42
+
43
+ it("replace swaps the catalog wholesale for its catalogId", () => {
44
+ const vendored: PickerCatalogInput = { nodeType: "setting", label: "Setting", catalogId: "setting", kind: "single",
45
+ valueField: "setting", options: [{ id: "forest", label: "Forest", promptHint: "in a forest", term: "forest" }] }
46
+ const out = composePickerCatalogs(base, [{ id: "p", catalogId: "setting", mode: "replace", catalog: vendored }])
47
+ expect(out[0].options!.map((o: PickerOption) => o.id)).toEqual(["forest"])
48
+ })
49
+
50
+ it("applies packs in registration order and throws for an unknown catalogId", () => {
51
+ expect(() => composePickerCatalogs(base, [{ id: "p", catalogId: "nope", mode: "deny", denyIds: ["x"] }]))
52
+ .toThrow(/unknown catalog id "nope"/i)
53
+ })
54
+ })
55
+
56
+ describe("registry + version", () => {
57
+ it("register bumps the version; reset clears and bumps", () => {
58
+ const v0 = catalogPacksVersion()
59
+ registerCatalogPack({ id: "a", catalogId: "setting", mode: "deny", denyIds: ["beach"] })
60
+ expect(getRegisteredCatalogPacks().map((p: CatalogPack) => p.id)).toEqual(["a"])
61
+ expect(catalogPacksVersion()).toBeGreaterThan(v0)
62
+ const v1 = catalogPacksVersion()
63
+ resetCatalogPacks()
64
+ expect(getRegisteredCatalogPacks()).toEqual([])
65
+ expect(catalogPacksVersion()).toBeGreaterThan(v1)
66
+ })
67
+
68
+ it("rejects a duplicate pack id", () => {
69
+ registerCatalogPack({ id: "a", catalogId: "setting", mode: "deny", denyIds: ["beach"] })
70
+ expect(() => registerCatalogPack({ id: "a", catalogId: "setting", mode: "deny", denyIds: ["forest"] }))
71
+ .toThrow(/duplicate/i)
72
+ })
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Compact hint mode over pack-added options
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * `PickerOption.term` is REQUIRED at the type level, but a pack is compiled
81
+ * separately — a vendored bundle built against a `@nodaro/prompts` that predates
82
+ * `term` hands us an option object with no such property at run time. The cast
83
+ * reproduces that exactly; without composition-time resolution the option would
84
+ * inject its full hint in full mode and `undefined` (i.e. nothing) in compact.
85
+ */
86
+ // A pack literal WITHOUT `term` must type-check against the pack input
87
+ // types (`PickerOptionInput`) — `term` is author-optional and resolved at
88
+ // composition. The helper is deliberately an identity on the INPUT type, not a
89
+ // cast to the registry's output type.
90
+ function legacyOption(o: Omit<PickerOption, "term">): PickerOptionInput {
91
+ return o as PickerOption
92
+ }
93
+
94
+ describe("pack options resolve a term at composition", () => {
95
+ it("extend: a term-less pack option gets the derived term", () => {
96
+ const out = composePickerCatalogs(base, [{
97
+ id: "legacy", catalogId: "setting", mode: "extend",
98
+ options: [legacyOption({ id: "shul-hall", label: "Shul Hall", promptHint: "in a synagogue hall" })],
99
+ }])
100
+ expect(out[0].options!.find((o: PickerOption) => o.id === "shul-hall")!.term).toBe("shul hall")
101
+ })
102
+
103
+ it("replace: a term-less vendored catalog gets terms too", () => {
104
+ const vendored: PickerCatalogInput = {
105
+ nodeType: "setting", label: "Setting", catalogId: "setting", kind: "single", valueField: "setting",
106
+ options: [
107
+ legacyOption({ id: "shtiebel", label: "Shtiebel (Small Shul)", promptHint: "in a small prayer room" }),
108
+ legacyOption({ id: "nowhere", label: "None", promptHint: "" }),
109
+ ],
110
+ }
111
+ const out = composePickerCatalogs(base, [{ id: "legacy", catalogId: "setting", mode: "replace", catalog: vendored }])
112
+ const terms = Object.fromEntries(out[0].options!.map((o: PickerOption) => [o.id, o.term]))
113
+ // Parentheticals are stripped, and a no-op entry stays empty.
114
+ expect(terms).toEqual({ shtiebel: "shtiebel", nowhere: "" })
115
+ })
116
+
117
+ it("a pack-added value injects in BOTH modes, never an empty compact fragment", () => {
118
+ registerCatalogPack({
119
+ id: "legacy", catalogId: "setting", mode: "extend",
120
+ options: [legacyOption({ id: "shul-hall", label: "Shul Hall", promptHint: "in a synagogue hall" })],
121
+ })
122
+ // The registry view is what the hint path reads.
123
+ const opt = getPickerCatalog("setting")!.options!.find((o: PickerOption) => o.id === "shul-hall")
124
+ expect(opt?.term).toBe("shul hall")
125
+
126
+ const node = { id: "n1", type: "setting", data: { setting: "shul-hall" } }
127
+ expect(getParameterPromptHint(node)).toBe("in a synagogue hall")
128
+ expect(getParameterPromptHint({ ...node, data: { ...node.data, hintMode: "compact" } })).toBe("shul hall")
129
+ })
130
+ })
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { computePackSidecarCoverage } from "../catalog-sidecar-coverage.js"
3
+ import type { CatalogPack } from "../catalog-packs.js"
4
+
5
+ const pack: CatalogPack = {
6
+ id: "sai/setting", catalogId: "setting", mode: "extend",
7
+ options: [{ id: "shul-hall", label: "Shul Hall", promptHint: "in a synagogue hall", term: "shul hall" }],
8
+ sidecars: { he: { "shul-hall": { label: "אולם בית כנסת" } } }, // he present
9
+ exemptSidecarLocales: ["hi", "ja", "ko", "zh-CN", "ru", "ar"], // declared exemption
10
+ }
11
+
12
+ describe("computePackSidecarCoverage", () => {
13
+ it("reports missing non-exempt locales and lists the declared exemptions", () => {
14
+ const r = computePackSidecarCoverage(pack)
15
+ // he ok; hi/ja/ko/zh-CN/ru/ar exempt; es/fr/de/pt-BR missing (1 entry each)
16
+ expect(r.exempted.map((e) => e.locale).sort()).toEqual(["ar", "hi", "ja", "ko", "ru", "zh-CN"].sort())
17
+ expect(r.missing.map((m) => m.locale).sort()).toEqual(["de", "es", "fr", "pt-BR"].sort())
18
+ expect(r.missing.every((m) => m.id === "shul-hall")).toBe(true)
19
+ })
20
+
21
+ it("full 11-locale coverage yields zero missing", () => {
22
+ const locales = ["es","fr","de","pt-BR","ru","hi","ja","ko","zh-CN","he","ar"] as const
23
+ const full: CatalogPack = { ...pack, exemptSidecarLocales: [],
24
+ sidecars: Object.fromEntries(locales.map((l) => [l, { "shul-hall": { label: "x" } }])) }
25
+ expect(computePackSidecarCoverage(full).missing).toEqual([])
26
+ })
27
+ })
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PICKER_CATALOGS, type PickerOption } from "../picker-catalogs.js"
3
+ import { TERM_MAX_CHARS, deriveTerm, isSuspiciousDerivedTerm } from "../term.js"
4
+
5
+ /** A term is a phrase, not a sentence (see the convention in `term.ts`). */
6
+ const TERM_MAX_WORDS = 8
7
+
8
+ /**
9
+ * Two ids in one catalog may share a term ONLY where they are deliberately the
10
+ * same thing filed twice. `music-genre` cross-lists Amapiano under both the
11
+ * electronic and the global groupings, so both ids legitimately inject
12
+ * "amapiano". Anything else is a copy-paste that makes two picks
13
+ * indistinguishable downstream — widen this set only for another deliberate
14
+ * cross-listing, never to silence a collision.
15
+ */
16
+ const DUPLICATE_TERM_EXCEPTIONS: ReadonlySet<string> = new Set(["music-genre • amapiano"])
17
+
18
+ /**
19
+ * The compact-term guard.
20
+ *
21
+ * Every catalog option carries a `term` — the short professional phrase a
22
+ * consumer injects in compact hint mode. `term` is RESOLVED at build time
23
+ * (`resolveTerm`): an authored `term` on the catalog entry when there is one,
24
+ * the mechanically-derived label otherwise.
25
+ *
26
+ * The derivation is safe for the large majority of labels, and predictably
27
+ * WRONG for a few recognizable shapes — UI compounds ("None / Hard Cut",
28
+ * "Fog / Mist"), parenthetical annotations ("Ultra-wide (14mm)",
29
+ * "ISO 1600 (visible grain)"), and — in the catalogs listed below — bare
30
+ * modifier words that only become a trade term with their category noun
31
+ * attached. This test fails for exactly those entries, so the fix is to
32
+ * author an explicit `term` on the catalog entry rather than to ship a
33
+ * mangled fragment into every downstream prompt.
34
+ */
35
+
36
+ /**
37
+ * Catalogs whose labels are MODIFIERS, not standalone terms: a single derived
38
+ * word there is suspicious and must be authored explicitly.
39
+ *
40
+ * - `lighting` — the standard trade terms literally contain the category
41
+ * noun: "Short"/"Broad"/"Loop"/"Butterfly"/"Split"/"Hard"
42
+ * mean nothing alone and must read "short lighting",
43
+ * "loop lighting", "hard light". They also collide head-on
44
+ * with common English words, which is the worst case for a
45
+ * bare injection.
46
+ * - `color-look` — "Warm"/"Cool"/"Faded"/"Vibrant" are grade adjectives; the
47
+ * term a colorist writes is "warm grade", "cool grade".
48
+ * - `mood` — the labels are bare emotion adjectives ("Happy", "Sad",
49
+ * "Tense"). Injected alone a bare adjective has nothing to
50
+ * attach to; the catalog describes the SUBJECT's emotional
51
+ * state, so the term names the expression or demeanor the
52
+ * hint spells out — "melancholic expression", "cocky
53
+ * smirk". ("melancholic mood" would be the wrong register:
54
+ * it points the model at the scene, not the face.)
55
+ *
56
+ * Deliberately NOT in the set:
57
+ * - `tone` is free text, not a catalog at all.
58
+ * - `aesthetic` / `style` / `render-quality` bare labels are proper names of
59
+ * the thing itself ("cottagecore", "noir", "octane render") — already the
60
+ * professional term, so flagging them would be noise.
61
+ * - the multi-dim `person` / `styling` dimensions are bare by nature (dozens
62
+ * of one-word options per dimension); they are left out to keep this guard
63
+ * a work list rather than a wall. Widen the set here when a dimension is
64
+ * shown to inject ambiguously.
65
+ */
66
+ const BARE_WORD_SUSPICIOUS_CATALOG_IDS: ReadonlySet<string> = new Set([
67
+ "lighting",
68
+ "color-look",
69
+ "mood",
70
+ ])
71
+
72
+ interface Row {
73
+ readonly catalogId: string
74
+ readonly option: PickerOption
75
+ }
76
+
77
+ /** Every option in the frozen upstream base, single-dim + multi-dim alike. */
78
+ function allRows(): Row[] {
79
+ const rows: Row[] = []
80
+ for (const c of PICKER_CATALOGS) {
81
+ for (const o of c.options ?? []) rows.push({ catalogId: c.catalogId, option: o })
82
+ for (const d of c.dimensions ?? []) {
83
+ for (const o of d.options) rows.push({ catalogId: c.catalogId, option: o })
84
+ }
85
+ }
86
+ return rows
87
+ }
88
+
89
+ const ROWS = allRows()
90
+
91
+ describe("catalog compact terms", () => {
92
+ it("walks a non-trivial number of options", () => {
93
+ expect(ROWS.length).toBeGreaterThan(1000)
94
+ })
95
+
96
+ it("every label whose derived term is unsafe has an explicit term authored", () => {
97
+ const flagged: string[] = []
98
+ for (const { catalogId, option } of ROWS) {
99
+ const bareWordSuspicious = BARE_WORD_SUSPICIOUS_CATALOG_IDS.has(catalogId)
100
+ if (!isSuspiciousDerivedTerm(option.label, { bareWordSuspicious })) continue
101
+ const derived = deriveTerm(option.label)
102
+ // An authored term is anything that is NOT just the derivation; when the
103
+ // two are equal, nothing was authored and the mangled form would ship.
104
+ if (option.term !== derived) continue
105
+ flagged.push(`${catalogId} • ${option.id} • ${option.label} → ${derived}`)
106
+ }
107
+ expect(
108
+ flagged,
109
+ `${flagged.length} catalog entries need an explicit \`term\` (label cannot be safely lowercased):\n${flagged.join("\n")}`,
110
+ ).toEqual([])
111
+ })
112
+
113
+ it("every resolved term is well-formed", () => {
114
+ const bad: string[] = []
115
+ for (const { catalogId, option } of ROWS) {
116
+ const term = option.term
117
+ const where = `${catalogId} • ${option.id} • ${option.label}`
118
+ if (typeof term !== "string") {
119
+ bad.push(`${where} → term is not a string (a builder path forgot to resolve it)`)
120
+ continue
121
+ }
122
+ if (term.length > TERM_MAX_CHARS) {
123
+ bad.push(`${where} → ${term.length} chars, over the ${TERM_MAX_CHARS} cap: "${term}"`)
124
+ }
125
+ if (term.includes("\n")) bad.push(`${where} → contains a newline: ${JSON.stringify(term)}`)
126
+ if (term.endsWith(".")) bad.push(`${where} → ends with a period: "${term}"`)
127
+ if (term === "" && option.promptHint !== "") {
128
+ bad.push(`${where} → empty term on an entry that DOES inject a hint`)
129
+ }
130
+ const words = term.trim().split(/\s+/).filter(Boolean)
131
+ if (words.length > TERM_MAX_WORDS) {
132
+ bad.push(`${where} → ${words.length} words, over the ${TERM_MAX_WORDS} cap: "${term}"`)
133
+ }
134
+ }
135
+ expect(bad, `${bad.length} malformed terms:\n${bad.join("\n")}`).toEqual([])
136
+ })
137
+
138
+ it("no two ids in a catalog resolve to the same term", () => {
139
+ const byCatalog = new Map<string, Map<string, string[]>>()
140
+ for (const { catalogId, option } of ROWS) {
141
+ if (!option.term) continue // no-op "auto"/"none" entries all inject nothing
142
+ let terms = byCatalog.get(catalogId)
143
+ if (!terms) byCatalog.set(catalogId, (terms = new Map()))
144
+ const ids = terms.get(option.term)
145
+ if (ids) ids.push(option.id)
146
+ else terms.set(option.term, [option.id])
147
+ }
148
+ const collisions: string[] = []
149
+ for (const [catalogId, terms] of byCatalog) {
150
+ for (const [term, ids] of terms) {
151
+ const unique = [...new Set(ids)]
152
+ if (unique.length < 2) continue
153
+ if (DUPLICATE_TERM_EXCEPTIONS.has(`${catalogId} • ${term}`)) continue
154
+ collisions.push(`${catalogId} • "${term}" ← ${unique.join(", ")}`)
155
+ }
156
+ }
157
+ expect(
158
+ collisions,
159
+ `${collisions.length} terms are shared by two ids, so the two picks inject the same fragment:\n${collisions.join("\n")}`,
160
+ ).toEqual([])
161
+ })
162
+ })
@@ -48,8 +48,9 @@ describe("characterLockToRefLock", () => {
48
48
  expect(lock.text).toContain("must match exactly")
49
49
  })
50
50
 
51
- it("coerces undefined -> soft (DEFAULT_IDENTITY_LOCK, the accepted back-compat behavior)", () => {
52
- expect(characterLockToRefLock(undefined)).toEqual(characterLockToRefLock("soft"))
51
+ it("coerces undefined -> off (DEFAULT_IDENTITY_LOCK: characters default to no lock)", () => {
52
+ expect(characterLockToRefLock(undefined)).toEqual(characterLockToRefLock("off"))
53
+ expect(characterLockToRefLock(undefined)).toEqual({ enabled: false })
53
54
  })
54
55
 
55
56
  it("soft and strict escalate like getIdentityLockClause but stay reference-bound", () => {
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { readFileSync, readdirSync, statSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ /**
6
+ * Content-free contract guard for @nodaro/prompts (FSL-1.1-Apache-2.0).
7
+ *
8
+ * Every published version of this package is an IRREVOCABLE grant, so it must
9
+ * carry no deployment-specific prompt content and no machinery that switches
10
+ * prompt output on the deployment's environment. A fixed clause a deployment
11
+ * folds into prompts (a "modesty" clause, a brand-safety negative, a forced
12
+ * vocal gender) is the DEPLOYMENT's own content: it lives in that deployment's
13
+ * registered `PromptPolicy` module (backend/src/lib/prompt-policy.ts — applied
14
+ * server-side, after this package's pure assembly runs), never in this package
15
+ * and never read from `process.env` inside this package.
16
+ *
17
+ * The concrete, generic signature of a violation is a `process.env` read in the
18
+ * package source: it is how the earlier fork gated a clause
19
+ * (`process.env.<FLAG> === "true"`), and a published package that branches on a
20
+ * deployment env var is by construction carrying deployment behavior. This is a
21
+ * capability/invariant guard, not a customer-symbol denylist — it names no
22
+ * deployment and stays correct as new prompt content is added.
23
+ *
24
+ * __tests__ is excluded (never bundled at runtime; and a guard necessarily
25
+ * mentions the pattern it forbids).
26
+ */
27
+ function walk(dir: string): string[] {
28
+ return readdirSync(dir).flatMap((name) => {
29
+ const p = join(dir, name)
30
+ if (statSync(p).isDirectory()) return name === "__tests__" ? [] : walk(p)
31
+ return p.endsWith(".ts") ? [p] : []
32
+ })
33
+ }
34
+
35
+ // packages/prompts/src/__tests__ -> packages/prompts/src
36
+ const SRC_DIR = join(__dirname, "..")
37
+
38
+ describe("content-free contract", () => {
39
+ it("no prompts source file reads process.env (deployment content must live in a registered PromptPolicy, not this package)", () => {
40
+ const offenders = walk(SRC_DIR).filter((f) =>
41
+ /process\s*\.\s*env\b/.test(readFileSync(f, "utf8")),
42
+ )
43
+ expect(offenders).toEqual([])
44
+ })
45
+ })
@@ -0,0 +1,41 @@
1
+ // DoD (spec §8 Phase 0): "SAI resets the 23 catalog files to upstream and
2
+ // adopts its vendored packs by registration." In-package form: a full vendored
3
+ // copy of a catalog registered as `replace` is adopted everywhere by
4
+ // registration alone, with the upstream base left byte-identical.
5
+ import { describe, it, expect, beforeEach } from "vitest"
6
+ import {
7
+ PICKER_CATALOGS,
8
+ getPickerCatalog,
9
+ getRegisteredPickerCatalogs,
10
+ projectAllCatalogs,
11
+ } from "../picker-catalogs.js"
12
+ import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
13
+ import type { PickerCatalog } from "../picker-catalogs.js"
14
+
15
+ beforeEach(() => resetCatalogPacks())
16
+
17
+ describe("DoD — adopt a vendored replacement pack by registration", () => {
18
+ it("replaces one catalog wholesale across every read; base untouched", () => {
19
+ const upstreamMood = PICKER_CATALOGS.find((c) => c.catalogId === "mood")!
20
+ const upstreamSnapshot = JSON.stringify(PICKER_CATALOGS)
21
+
22
+ // SAI's vendored full copy: same shape, curated option set.
23
+ const vendored: PickerCatalog = {
24
+ ...upstreamMood,
25
+ options: [
26
+ { id: "calm", label: "Calm", promptHint: "with a calm mood", term: "calm", category: upstreamMood.options![0].category },
27
+ ],
28
+ }
29
+ registerCatalogPack({ id: "sai/mood", catalogId: "mood", mode: "replace", catalog: vendored })
30
+
31
+ // Every funnel/route/enumeration reflects the vendored copy…
32
+ expect(getPickerCatalog("mood")!.options!.map((o) => o.id)).toEqual(["calm"])
33
+ expect(getRegisteredPickerCatalogs().find((c) => c.catalogId === "mood")!.options!.map((o) => o.id)).toEqual([
34
+ "calm",
35
+ ])
36
+ expect(projectAllCatalogs().find((c) => c.catalogId === "mood")!.options!.map((o) => o.id)).toEqual(["calm"])
37
+
38
+ // …while the frozen upstream base is byte-identical (invariant: never edited in place).
39
+ expect(JSON.stringify(PICKER_CATALOGS)).toBe(upstreamSnapshot)
40
+ })
41
+ })