@smeltjs/core 0.5.0 → 0.6.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 (97) hide show
  1. package/dist/apply.d.ts.map +1 -1
  2. package/dist/apply.js +8 -2
  3. package/dist/apply.js.map +1 -1
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +4 -2
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/cli/report.d.ts +10 -1
  8. package/dist/cli/report.d.ts.map +1 -1
  9. package/dist/cli/report.js +16 -1
  10. package/dist/cli/report.js.map +1 -1
  11. package/dist/cli/subcommands/flags.d.ts +3 -0
  12. package/dist/cli/subcommands/flags.d.ts.map +1 -1
  13. package/dist/cli/subcommands/flags.js +16 -3
  14. package/dist/cli/subcommands/flags.js.map +1 -1
  15. package/dist/cli/subcommands/smelt.d.ts +3 -0
  16. package/dist/cli/subcommands/smelt.d.ts.map +1 -1
  17. package/dist/cli/subcommands/smelt.js +5 -1
  18. package/dist/cli/subcommands/smelt.js.map +1 -1
  19. package/dist/cli/subcommands/stats.d.ts +8 -3
  20. package/dist/cli/subcommands/stats.d.ts.map +1 -1
  21. package/dist/cli/subcommands/stats.js +15 -5
  22. package/dist/cli/subcommands/stats.js.map +1 -1
  23. package/dist/errors.d.ts +10 -0
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +10 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/hooks/focus-terms.d.ts +57 -0
  28. package/dist/hooks/focus-terms.d.ts.map +1 -0
  29. package/dist/hooks/focus-terms.js +230 -0
  30. package/dist/hooks/focus-terms.js.map +1 -0
  31. package/dist/hooks/guard-core.d.ts +8 -16
  32. package/dist/hooks/guard-core.d.ts.map +1 -1
  33. package/dist/hooks/guard-core.js +22 -120
  34. package/dist/hooks/guard-core.js.map +1 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/ops/index.d.ts +3 -3
  40. package/dist/ops/index.d.ts.map +1 -1
  41. package/dist/ops/index.js +2 -2
  42. package/dist/ops/index.js.map +1 -1
  43. package/dist/ops/verbs.d.ts +57 -1
  44. package/dist/ops/verbs.d.ts.map +1 -1
  45. package/dist/ops/verbs.js +49 -1
  46. package/dist/ops/verbs.js.map +1 -1
  47. package/dist/plan/auto.d.ts +9 -2
  48. package/dist/plan/auto.d.ts.map +1 -1
  49. package/dist/plan/auto.js +14 -2
  50. package/dist/plan/auto.js.map +1 -1
  51. package/dist/plan/diff.d.ts +45 -0
  52. package/dist/plan/diff.d.ts.map +1 -0
  53. package/dist/plan/diff.js +284 -0
  54. package/dist/plan/diff.js.map +1 -0
  55. package/dist/plan/json.d.ts +37 -0
  56. package/dist/plan/json.d.ts.map +1 -0
  57. package/dist/plan/json.js +181 -0
  58. package/dist/plan/json.js.map +1 -0
  59. package/dist/plan/kind.d.ts +28 -0
  60. package/dist/plan/kind.d.ts.map +1 -0
  61. package/dist/plan/kind.js +57 -0
  62. package/dist/plan/kind.js.map +1 -0
  63. package/dist/plan/offsets.d.ts +8 -0
  64. package/dist/plan/offsets.d.ts.map +1 -0
  65. package/dist/plan/offsets.js +19 -0
  66. package/dist/plan/offsets.js.map +1 -0
  67. package/dist/plan/planners.d.ts +15 -5
  68. package/dist/plan/planners.d.ts.map +1 -1
  69. package/dist/plan/planners.js +13 -5
  70. package/dist/plan/planners.js.map +1 -1
  71. package/dist/plan/structural.d.ts.map +1 -1
  72. package/dist/plan/structural.js +106 -14
  73. package/dist/plan/structural.js.map +1 -1
  74. package/dist/retrieve.d.ts +22 -1
  75. package/dist/retrieve.d.ts.map +1 -1
  76. package/dist/retrieve.js +59 -0
  77. package/dist/retrieve.js.map +1 -1
  78. package/dist/smelter.d.ts +4 -0
  79. package/dist/smelter.d.ts.map +1 -1
  80. package/dist/smelter.js +4 -0
  81. package/dist/smelter.js.map +1 -1
  82. package/dist/stats.d.ts +16 -1
  83. package/dist/stats.d.ts.map +1 -1
  84. package/dist/stats.js +29 -0
  85. package/dist/stats.js.map +1 -1
  86. package/dist/store-dir.d.ts +9 -2
  87. package/dist/store-dir.d.ts.map +1 -1
  88. package/dist/store-dir.js +48 -7
  89. package/dist/store-dir.js.map +1 -1
  90. package/dist/store.d.ts +4 -2
  91. package/dist/store.d.ts.map +1 -1
  92. package/dist/store.js +12 -2
  93. package/dist/store.js.map +1 -1
  94. package/dist/types.d.ts +116 -2
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAGV,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,aAAa,EACb,OAAO,EACP,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,0DAA0D;AAC1D,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,MAAM,CAAC;AAEzD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,EAAE,aACoE,CAAC;AAEjG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,eAAO,MAAM,2BAA2B,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAKzF,CAAC;AAEJ;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,gBAAgB,EAC1B,IAAI,GAAE,aAA6B,GAClC,aAAa,CAIf;AAQD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,GAAE,gBAA4B,EACtC,aAAa,CAAC,EAAE,aAAa,GAC5B,aAAa,CAef;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,mFAAmF;IACnF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,YAAY,EACnB,OAAO,GAAE,YAAiB,GACzB,WAAW,CA6Eb;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,GAAG,MAAM,CAiB5E","sourcesContent":["import { OverlappingElisionError, RangeOutOfBoundsError, UnknownHashError } from './errors.ts';\nimport { HASH_LENGTH } from './hash.ts';\nimport { LANGUAGE_PROFILES } from './lang/registry.ts';\nimport type {\n AppliedElision,\n ByteRange,\n DetectedLanguage,\n ElisionPlan,\n ElisionStore,\n MarkerPricing,\n Measure,\n SmeltResult,\n} from './types.ts';\n\n/** Everything the marker text is allowed to depend on. */\nexport interface MarkerInfo {\n readonly hash: string;\n readonly bytes: number;\n readonly rule: string;\n readonly explanation: string;\n}\n\nexport type MarkerBuilder = (info: MarkerInfo) => string;\n\n/**\n * The version of the marker format itself, carried **in band** in every marker.\n *\n * The marker is the one part of smelt a *model* sees, and it goes into prompts.\n * Changing its shape changes model behaviour downstream and shows up as worse output\n * with no error anywhere — this project's signature failure mode, shipped as a patch\n * release. So the wire surface is frozen from 0.1 and treated as 1.0\n * (`CONTRIBUTING.md` § \"Two promises, not one\"), and a future format is *additive and\n * identifiable*: `smelt/v2` markers can coexist with `smelt/v1` ones, and a consumer\n * parsing markers can tell which it is holding. A format that changed silently would\n * be a substitution; this makes it a declaration.\n *\n * `test/guards/marker-format.test.ts` pins the rendered marker per version and fails if\n * the format moves without the version moving.\n */\nexport const MARKER_FORMAT_VERSION = 'v1';\n\n/**\n * The default marker.\n *\n * Its shape is the user-facing form of Laws 2 and 3, in one line the model reads:\n * *which format this is* (the version), *what was removed* (the explanation), *how\n * much* (the byte count), and *how to get it back* (the hash). Anything that cannot\n * fill in all of those is not allowed to be an elision.\n *\n * `<<…>>` rather than a Unicode bracket because it survives every tokenizer, terminal,\n * and diff tool without becoming three tokens of nothing.\n */\nexport const defaultMarker: MarkerBuilder = ({ explanation, bytes, hash }) =>\n `<<smelt/${MARKER_FORMAT_VERSION}: ${explanation} (${String(bytes)}B) — retrieve(\"${hash}\")>>`;\n\n/**\n * Line-comment leaders per language: the marker always lands as a comment in the\n * survivor's own syntax, because a bare marker line breaks the syntax of what remains\n * around it — in **every** grammar this was tested against, not just the indented ones.\n *\n * The failure classes, each verified by reparsing a bare-marker survivor:\n *\n * - **python** — significant indentation means a parse error does not stay local.\n * The ERROR node swallows the *neighbouring definitions too* — the survivor stops\n * being Python at all, not just at the marker line.\n * - **ruby** and **bash** — the marker *begins with* `<<`, which both languages read\n * as a heredoc operator. A bare marker line opens a heredoc whose terminator never\n * arrives, and everything after it — every kept declaration — is swallowed into a\n * string literal.\n * - **php** — `<<` is an operator here too: the kept function after a bare marker is\n * re-typed into an anonymous-function operand inside the marker's binary\n * expression. The kept declaration is no longer a declaration in the survivor.\n * - **kotlin**, **swift**, and the rest of the brace-delimited set (typescript, tsx,\n * javascript, rust, go, java, c, cpp, c_sharp) — the folk claim that braces keep a\n * parse error local is **empirically false**: reparsing each language's fixture\n * survivor with its own bundled grammar shows ERROR nodes spanning the kept\n * declarations (a C function's signature absorbed into an ERROR, fifteen cascading\n * ERRORs in swift, and so on). The survivor-reparse guard in\n * `test/guards/structural.test.ts` now asserts the opposite property for every\n * structural language: the survivor reparses with no new issues.\n *\n * Only `'unknown'` keeps the bare marker — lexical text has no syntax to break.\n *\n * This does **not** move the frozen wire surface. The `<<smelt/v1: … >>` core is\n * rendered by {@link defaultMarker}, byte-identical and still versioned in band; the\n * leader is part of the substituted marker text, so `outputRange` covers it and\n * reconstruction stays byte-exact. A comment leader in the survivor's own syntax is\n * the one wrapping that cannot change what a model reads out of the marker.\n *\n * A derived view: each leader is the `markerLeader` fact on the language's\n * {@link LanguageProfile} (`src/lang/`), collected here so marker construction keeps\n * one lookup table.\n */\nexport const MARKER_LINE_COMMENT_LEADERS: Readonly<Partial<Record<DetectedLanguage, string>>> =\n Object.fromEntries(\n Object.values(LANGUAGE_PROFILES)\n .filter((profile) => profile.markerLeader !== undefined)\n .map((profile) => [profile.id, profile.markerLeader]),\n );\n\n/**\n * The marker builder for a language: {@link defaultMarker}, wrapped in the language's\n * line-comment leader when {@link MARKER_LINE_COMMENT_LEADERS} names one — so a Python\n * survivor still parses as Python. Everything else gets `base` unchanged.\n */\nexport function markerForLanguage(\n language: DetectedLanguage,\n base: MarkerBuilder = defaultMarker,\n): MarkerBuilder {\n const leader = MARKER_LINE_COMMENT_LEADERS[language];\n if (leader === undefined) return base;\n return (info) => `${leader}${base(info)}`;\n}\n\n/**\n * A stand-in hash of the real length, so a marker can be priced before the cut that\n * would earn it exists. Marker cost depends on the hash's *length*, never its value.\n */\nconst PLACEHOLDER_HASH = '0'.repeat(HASH_LENGTH);\n\n/**\n * The one adapter behind the {@link MarkerPricing} seam.\n *\n * Marker cost is this module's fact: `applyPlan` renders the marker, so only this\n * module can price it without guessing. The pricing is built from the **exact builder\n * `applyPlan` will use** — the same resolution, in the same order: a caller-supplied\n * builder (`SmelterConfig.marker` / `ApplyOptions.marker`) wins wholesale, otherwise\n * the language's leader-wrapped default via {@link markerForLanguage}.\n *\n * The custom-builder leg is load-bearing, not a convenience: a caller who installs a\n * longer `MarkerBuilder` changes what every elision costs, and a planner still pricing\n * the *default* marker would keep planning elisions the real marker makes\n * unprofitable — cuts that grow the output, silently. Pricing with the builder's own\n * rendering closes that hole: `costBytes` measures the marker *that builder* would\n * emit, byte for byte.\n *\n * `createSmelter` (and through it, the CLI) calls this centrally, once per smelt call;\n * a caller driving `planLexical`/`planStructural` directly builds its own and puts it\n * on the {@link PlanInput}.\n */\nexport function markerPricing(\n language: DetectedLanguage = 'unknown',\n markerBuilder?: MarkerBuilder,\n): MarkerPricing {\n // The same resolution applyPlan performs: a supplied builder wins wholesale.\n const build = markerBuilder ?? markerForLanguage(language);\n return {\n costBytes: (reason, elidedBytes) =>\n Buffer.byteLength(\n build({\n hash: PLACEHOLDER_HASH,\n bytes: elidedBytes,\n rule: reason.rule,\n explanation: reason.explanation,\n }),\n 'utf8',\n ),\n };\n}\n\nexport interface ApplyOptions {\n /**\n * Overrides the marker builder. The default follows the *plan's* language —\n * {@link markerForLanguage} — so the documented composition\n * `planStructural → applyPlan` lands a `# `-led marker in python without the caller\n * wiring it, the same as `createSmelter` does. A bare {@link defaultMarker} in a\n * python survivor is exactly the parse-breaking failure the leader exists to prevent.\n */\n readonly marker?: MarkerBuilder;\n /** A consumer-supplied counter. See {@link Measure}; the budget stays in bytes. */\n readonly measure?: Measure;\n}\n\n/**\n * Turn a plan into text.\n *\n * This is the only function in smelt that removes anything, and it contains no\n * judgement at all: it validates the plan, stores every removed run, substitutes\n * markers, and records where each marker landed. All the deciding happens in a\n * {@link Planner}, which is why a plan can be reviewed before a byte moves.\n *\n * @throws {RangeOutOfBoundsError} if a range falls outside the input's UTF-8 bytes.\n * @throws {OverlappingElisionError} if two ranges overlap — applying both would\n * corrupt the output, and picking a winner would be a silent guess.\n */\nexport function applyPlan(\n text: string,\n plan: ElisionPlan,\n store: ElisionStore,\n options: ApplyOptions = {},\n): SmeltResult {\n const buildMarker = options.marker ?? markerForLanguage(plan.language);\n const input = Buffer.from(text, 'utf8');\n\n const ordered = plan.elisions.toSorted((a, b) => a.range.start - b.range.start);\n for (const { range } of ordered) assertInBounds(range, input.length);\n for (let i = 1; i < ordered.length; i += 1) {\n const previous = ordered[i - 1]!;\n const current = ordered[i]!;\n if (current.range.start < previous.range.end) {\n throw new OverlappingElisionError(\n `smelt: plan from \"${plan.planner}\" elides overlapping ranges ` +\n `[${previous.range.start},${previous.range.end}) and ` +\n `[${current.range.start},${current.range.end}). A plan must be a partition.`,\n );\n }\n }\n\n const pieces: Buffer[] = [];\n const applied: AppliedElision[] = [];\n let cursor = 0;\n let outputBytes = 0;\n\n for (const { range, reason } of ordered) {\n const kept = input.subarray(cursor, range.start);\n pieces.push(kept);\n outputBytes += kept.length;\n\n const removed = input.subarray(range.start, range.end);\n const removedText = removed.toString('utf8');\n const hash = store.put(removedText);\n const marker = buildMarker({\n hash,\n bytes: removed.length,\n rule: reason.rule,\n explanation: reason.explanation,\n });\n const markerBuffer = Buffer.from(marker, 'utf8');\n pieces.push(markerBuffer);\n\n applied.push({\n hash,\n range,\n outputRange: { start: outputBytes, end: outputBytes + markerBuffer.length },\n bytes: removed.length,\n reason,\n marker,\n });\n outputBytes += markerBuffer.length;\n cursor = range.end;\n }\n\n const tail = input.subarray(cursor);\n pieces.push(tail);\n outputBytes += tail.length;\n\n const output = Buffer.concat(pieces).toString('utf8');\n const measure = options.measure;\n\n return {\n text: output,\n inputBytes: input.length,\n outputBytes,\n planner: plan.planner,\n language: plan.language,\n elisions: applied,\n ...(measure === undefined\n ? {}\n : {\n measured: {\n measure: measure.id,\n unit: measure.unit,\n input: measure.count(text),\n output: measure.count(output),\n },\n }),\n };\n}\n\n/**\n * Put it all back. `reconstruct(smelt(x), store) === x`, byte for byte — this is Law 3\n * expressed as an executable equation, and `test/guards/reversibility.test.ts` asserts\n * it on every input the suite knows about.\n *\n * Reads through `peek`, **not** `retrieve`: `retrieveCalls` and the expansion rate\n * exist to count *the model asking for hidden material back* — the honest signal this\n * whole project sells. A caller reassembling the original (to diff it, to verify a\n * round trip, to write it to disk) is not that, and counting it would inflate the one\n * number that must never flatter. The guard in\n * `test/guards/expansion-counter.test.ts` pins this: reconstruction leaves every\n * counter exactly where it was.\n *\n * @throws {UnknownHashError} if the store no longer holds an elision's bytes.\n */\nexport function reconstruct(result: SmeltResult, store: ElisionStore): string {\n const output = Buffer.from(result.text, 'utf8');\n const ordered = result.elisions.toSorted((a, b) => a.outputRange.start - b.outputRange.start);\n const pieces: Buffer[] = [];\n let cursor = 0;\n\n for (const elision of ordered) {\n assertInBounds(elision.outputRange, output.length);\n const content = store.peek(elision.hash);\n if (content === undefined) throw new UnknownHashError(elision.hash);\n pieces.push(output.subarray(cursor, elision.outputRange.start));\n pieces.push(Buffer.from(content, 'utf8'));\n cursor = elision.outputRange.end;\n }\n pieces.push(output.subarray(cursor));\n\n return Buffer.concat(pieces).toString('utf8');\n}\n\nfunction assertInBounds(range: ByteRange, length: number): void {\n if (\n !Number.isInteger(range.start) ||\n !Number.isInteger(range.end) ||\n range.start < 0 ||\n range.end > length ||\n range.start >= range.end\n ) {\n throw new RangeOutOfBoundsError(\n `smelt: range [${range.start},${range.end}) is not a non-empty range inside ` +\n `${String(length)} bytes.`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAGV,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,aAAa,EACb,OAAO,EACP,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,0DAA0D;AAC1D,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,MAAM,CAAC;AAEzD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,EAAE,aACoE,CAAC;AAEjG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,eAAO,MAAM,2BAA2B,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAKzF,CAAC;AAEJ;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,gBAAgB,EAC1B,IAAI,GAAE,aAA6B,GAClC,aAAa,CAIf;AAQD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,GAAE,gBAA4B,EACtC,aAAa,CAAC,EAAE,aAAa,GAC5B,aAAa,CAef;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,mFAAmF;IACnF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,YAAY,EACnB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAmFb;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,GAAG,MAAM,CAiB5E","sourcesContent":["import { OverlappingElisionError, RangeOutOfBoundsError, UnknownHashError } from './errors.ts';\nimport { HASH_LENGTH } from './hash.ts';\nimport { LANGUAGE_PROFILES } from './lang/registry.ts';\nimport type {\n AppliedElision,\n ByteRange,\n DetectedLanguage,\n ElisionPlan,\n ElisionStore,\n MarkerPricing,\n Measure,\n SmeltResult,\n} from './types.ts';\n\n/** Everything the marker text is allowed to depend on. */\nexport interface MarkerInfo {\n readonly hash: string;\n readonly bytes: number;\n readonly rule: string;\n readonly explanation: string;\n}\n\nexport type MarkerBuilder = (info: MarkerInfo) => string;\n\n/**\n * The version of the marker format itself, carried **in band** in every marker.\n *\n * The marker is the one part of smelt a *model* sees, and it goes into prompts.\n * Changing its shape changes model behaviour downstream and shows up as worse output\n * with no error anywhere — this project's signature failure mode, shipped as a patch\n * release. So the wire surface is frozen from 0.1 and treated as 1.0\n * (`CONTRIBUTING.md` § \"Two promises, not one\"), and a future format is *additive and\n * identifiable*: `smelt/v2` markers can coexist with `smelt/v1` ones, and a consumer\n * parsing markers can tell which it is holding. A format that changed silently would\n * be a substitution; this makes it a declaration.\n *\n * `test/guards/marker-format.test.ts` pins the rendered marker per version and fails if\n * the format moves without the version moving.\n */\nexport const MARKER_FORMAT_VERSION = 'v1';\n\n/**\n * The default marker.\n *\n * Its shape is the user-facing form of Laws 2 and 3, in one line the model reads:\n * *which format this is* (the version), *what was removed* (the explanation), *how\n * much* (the byte count), and *how to get it back* (the hash). Anything that cannot\n * fill in all of those is not allowed to be an elision.\n *\n * `<<…>>` rather than a Unicode bracket because it survives every tokenizer, terminal,\n * and diff tool without becoming three tokens of nothing.\n */\nexport const defaultMarker: MarkerBuilder = ({ explanation, bytes, hash }) =>\n `<<smelt/${MARKER_FORMAT_VERSION}: ${explanation} (${String(bytes)}B) — retrieve(\"${hash}\")>>`;\n\n/**\n * Line-comment leaders per language: the marker always lands as a comment in the\n * survivor's own syntax, because a bare marker line breaks the syntax of what remains\n * around it — in **every** grammar this was tested against, not just the indented ones.\n *\n * The failure classes, each verified by reparsing a bare-marker survivor:\n *\n * - **python** — significant indentation means a parse error does not stay local.\n * The ERROR node swallows the *neighbouring definitions too* — the survivor stops\n * being Python at all, not just at the marker line.\n * - **ruby** and **bash** — the marker *begins with* `<<`, which both languages read\n * as a heredoc operator. A bare marker line opens a heredoc whose terminator never\n * arrives, and everything after it — every kept declaration — is swallowed into a\n * string literal.\n * - **php** — `<<` is an operator here too: the kept function after a bare marker is\n * re-typed into an anonymous-function operand inside the marker's binary\n * expression. The kept declaration is no longer a declaration in the survivor.\n * - **kotlin**, **swift**, and the rest of the brace-delimited set (typescript, tsx,\n * javascript, rust, go, java, c, cpp, c_sharp) — the folk claim that braces keep a\n * parse error local is **empirically false**: reparsing each language's fixture\n * survivor with its own bundled grammar shows ERROR nodes spanning the kept\n * declarations (a C function's signature absorbed into an ERROR, fifteen cascading\n * ERRORs in swift, and so on). The survivor-reparse guard in\n * `test/guards/structural.test.ts` now asserts the opposite property for every\n * structural language: the survivor reparses with no new issues.\n *\n * Only `'unknown'` keeps the bare marker — lexical text has no syntax to break.\n *\n * This does **not** move the frozen wire surface. The `<<smelt/v1: … >>` core is\n * rendered by {@link defaultMarker}, byte-identical and still versioned in band; the\n * leader is part of the substituted marker text, so `outputRange` covers it and\n * reconstruction stays byte-exact. A comment leader in the survivor's own syntax is\n * the one wrapping that cannot change what a model reads out of the marker.\n *\n * A derived view: each leader is the `markerLeader` fact on the language's\n * {@link LanguageProfile} (`src/lang/`), collected here so marker construction keeps\n * one lookup table.\n */\nexport const MARKER_LINE_COMMENT_LEADERS: Readonly<Partial<Record<DetectedLanguage, string>>> =\n Object.fromEntries(\n Object.values(LANGUAGE_PROFILES)\n .filter((profile) => profile.markerLeader !== undefined)\n .map((profile) => [profile.id, profile.markerLeader]),\n );\n\n/**\n * The marker builder for a language: {@link defaultMarker}, wrapped in the language's\n * line-comment leader when {@link MARKER_LINE_COMMENT_LEADERS} names one — so a Python\n * survivor still parses as Python. Everything else gets `base` unchanged.\n */\nexport function markerForLanguage(\n language: DetectedLanguage,\n base: MarkerBuilder = defaultMarker,\n): MarkerBuilder {\n const leader = MARKER_LINE_COMMENT_LEADERS[language];\n if (leader === undefined) return base;\n return (info) => `${leader}${base(info)}`;\n}\n\n/**\n * A stand-in hash of the real length, so a marker can be priced before the cut that\n * would earn it exists. Marker cost depends on the hash's *length*, never its value.\n */\nconst PLACEHOLDER_HASH = '0'.repeat(HASH_LENGTH);\n\n/**\n * The one adapter behind the {@link MarkerPricing} seam.\n *\n * Marker cost is this module's fact: `applyPlan` renders the marker, so only this\n * module can price it without guessing. The pricing is built from the **exact builder\n * `applyPlan` will use** — the same resolution, in the same order: a caller-supplied\n * builder (`SmelterConfig.marker` / `ApplyOptions.marker`) wins wholesale, otherwise\n * the language's leader-wrapped default via {@link markerForLanguage}.\n *\n * The custom-builder leg is load-bearing, not a convenience: a caller who installs a\n * longer `MarkerBuilder` changes what every elision costs, and a planner still pricing\n * the *default* marker would keep planning elisions the real marker makes\n * unprofitable — cuts that grow the output, silently. Pricing with the builder's own\n * rendering closes that hole: `costBytes` measures the marker *that builder* would\n * emit, byte for byte.\n *\n * `createSmelter` (and through it, the CLI) calls this centrally, once per smelt call;\n * a caller driving `planLexical`/`planStructural` directly builds its own and puts it\n * on the {@link PlanInput}.\n */\nexport function markerPricing(\n language: DetectedLanguage = 'unknown',\n markerBuilder?: MarkerBuilder,\n): MarkerPricing {\n // The same resolution applyPlan performs: a supplied builder wins wholesale.\n const build = markerBuilder ?? markerForLanguage(language);\n return {\n costBytes: (reason, elidedBytes) =>\n Buffer.byteLength(\n build({\n hash: PLACEHOLDER_HASH,\n bytes: elidedBytes,\n rule: reason.rule,\n explanation: reason.explanation,\n }),\n 'utf8',\n ),\n };\n}\n\nexport interface ApplyOptions {\n /**\n * Overrides the marker builder. The default follows the *plan's* language —\n * {@link markerForLanguage} — so the documented composition\n * `planStructural → applyPlan` lands a `# `-led marker in python without the caller\n * wiring it, the same as `createSmelter` does. A bare {@link defaultMarker} in a\n * python survivor is exactly the parse-breaking failure the leader exists to prevent.\n */\n readonly marker?: MarkerBuilder;\n /** A consumer-supplied counter. See {@link Measure}; the budget stays in bytes. */\n readonly measure?: Measure;\n}\n\n/**\n * Turn a plan into text.\n *\n * This is the only function in smelt that removes anything, and it contains no\n * judgement at all: it validates the plan, stores every removed run, substitutes\n * markers, and records where each marker landed. All the deciding happens in a\n * {@link Planner}, which is why a plan can be reviewed before a byte moves.\n *\n * @throws {RangeOutOfBoundsError} if a range falls outside the input's UTF-8 bytes.\n * @throws {OverlappingElisionError} if two ranges overlap — applying both would\n * corrupt the output, and picking a winner would be a silent guess.\n */\nexport function applyPlan(\n text: string,\n plan: ElisionPlan,\n store: ElisionStore,\n options: ApplyOptions = {},\n): SmeltResult {\n const buildMarker = options.marker ?? markerForLanguage(plan.language);\n const input = Buffer.from(text, 'utf8');\n\n const ordered = plan.elisions.toSorted((a, b) => a.range.start - b.range.start);\n for (const { range } of ordered) assertInBounds(range, input.length);\n for (let i = 1; i < ordered.length; i += 1) {\n const previous = ordered[i - 1]!;\n const current = ordered[i]!;\n if (current.range.start < previous.range.end) {\n throw new OverlappingElisionError(\n `smelt: plan from \"${plan.planner}\" elides overlapping ranges ` +\n `[${previous.range.start},${previous.range.end}) and ` +\n `[${current.range.start},${current.range.end}). A plan must be a partition.`,\n );\n }\n }\n\n const pieces: Buffer[] = [];\n const applied: AppliedElision[] = [];\n let cursor = 0;\n let outputBytes = 0;\n\n for (const { range, reason, names } of ordered) {\n const kept = input.subarray(cursor, range.start);\n pieces.push(kept);\n outputBytes += kept.length;\n\n const removed = input.subarray(range.start, range.end);\n const removedText = removed.toString('utf8');\n // Attributed to its rule: this is the one place bytes leave the text, so it is the\n // one place the store learns which rule cut them — the ledger's only source.\n const hash = store.put(removedText, reason);\n const marker = buildMarker({\n hash,\n bytes: removed.length,\n rule: reason.rule,\n explanation: reason.explanation,\n });\n const markerBuffer = Buffer.from(marker, 'utf8');\n pieces.push(markerBuffer);\n\n applied.push({\n hash,\n range,\n outputRange: { start: outputBytes, end: outputBytes + markerBuffer.length },\n bytes: removed.length,\n reason,\n marker,\n // The outline rides beside the marker, never inside it: `buildMarker` above was\n // handed the reason and nothing else, so the wire surface and its priced cost\n // are the same with or without names.\n ...(names === undefined ? {} : { names }),\n });\n outputBytes += markerBuffer.length;\n cursor = range.end;\n }\n\n const tail = input.subarray(cursor);\n pieces.push(tail);\n outputBytes += tail.length;\n\n const output = Buffer.concat(pieces).toString('utf8');\n const measure = options.measure;\n\n return {\n text: output,\n inputBytes: input.length,\n outputBytes,\n planner: plan.planner,\n language: plan.language,\n elisions: applied,\n ...(measure === undefined\n ? {}\n : {\n measured: {\n measure: measure.id,\n unit: measure.unit,\n input: measure.count(text),\n output: measure.count(output),\n },\n }),\n };\n}\n\n/**\n * Put it all back. `reconstruct(smelt(x), store) === x`, byte for byte — this is Law 3\n * expressed as an executable equation, and `test/guards/reversibility.test.ts` asserts\n * it on every input the suite knows about.\n *\n * Reads through `peek`, **not** `retrieve`: `retrieveCalls` and the expansion rate\n * exist to count *the model asking for hidden material back* — the honest signal this\n * whole project sells. A caller reassembling the original (to diff it, to verify a\n * round trip, to write it to disk) is not that, and counting it would inflate the one\n * number that must never flatter. The guard in\n * `test/guards/expansion-counter.test.ts` pins this: reconstruction leaves every\n * counter exactly where it was.\n *\n * @throws {UnknownHashError} if the store no longer holds an elision's bytes.\n */\nexport function reconstruct(result: SmeltResult, store: ElisionStore): string {\n const output = Buffer.from(result.text, 'utf8');\n const ordered = result.elisions.toSorted((a, b) => a.outputRange.start - b.outputRange.start);\n const pieces: Buffer[] = [];\n let cursor = 0;\n\n for (const elision of ordered) {\n assertInBounds(elision.outputRange, output.length);\n const content = store.peek(elision.hash);\n if (content === undefined) throw new UnknownHashError(elision.hash);\n pieces.push(output.subarray(cursor, elision.outputRange.start));\n pieces.push(Buffer.from(content, 'utf8'));\n cursor = elision.outputRange.end;\n }\n pieces.push(output.subarray(cursor));\n\n return Buffer.concat(pieces).toString('utf8');\n}\n\nfunction assertInBounds(range: ByteRange, length: number): void {\n if (\n !Number.isInteger(range.start) ||\n !Number.isInteger(range.end) ||\n range.start < 0 ||\n range.end > length ||\n range.start >= range.end\n ) {\n throw new RangeOutOfBoundsError(\n `smelt: range [${range.start},${range.end}) is not a non-empty range inside ` +\n `${String(length)} bytes.`,\n );\n }\n}\n"]}
package/dist/apply.js CHANGED
@@ -149,13 +149,15 @@ export function applyPlan(text, plan, store, options = {}) {
149
149
  const applied = [];
150
150
  let cursor = 0;
151
151
  let outputBytes = 0;
152
- for (const { range, reason } of ordered) {
152
+ for (const { range, reason, names } of ordered) {
153
153
  const kept = input.subarray(cursor, range.start);
154
154
  pieces.push(kept);
155
155
  outputBytes += kept.length;
156
156
  const removed = input.subarray(range.start, range.end);
157
157
  const removedText = removed.toString('utf8');
158
- const hash = store.put(removedText);
158
+ // Attributed to its rule: this is the one place bytes leave the text, so it is the
159
+ // one place the store learns which rule cut them — the ledger's only source.
160
+ const hash = store.put(removedText, reason);
159
161
  const marker = buildMarker({
160
162
  hash,
161
163
  bytes: removed.length,
@@ -171,6 +173,10 @@ export function applyPlan(text, plan, store, options = {}) {
171
173
  bytes: removed.length,
172
174
  reason,
173
175
  marker,
176
+ // The outline rides beside the marker, never inside it: `buildMarker` above was
177
+ // handed the reason and nothing else, so the wire surface and its priced cost
178
+ // are the same with or without names.
179
+ ...(names === undefined ? {} : { names }),
174
180
  });
175
181
  outputBytes += markerBuffer.length;
176
182
  cursor = range.end;
package/dist/apply.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"apply.js","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"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","sourcesContent":["import { OverlappingElisionError, RangeOutOfBoundsError, UnknownHashError } from './errors.ts';\nimport { HASH_LENGTH } from './hash.ts';\nimport { LANGUAGE_PROFILES } from './lang/registry.ts';\nimport type {\n AppliedElision,\n ByteRange,\n DetectedLanguage,\n ElisionPlan,\n ElisionStore,\n MarkerPricing,\n Measure,\n SmeltResult,\n} from './types.ts';\n\n/** Everything the marker text is allowed to depend on. */\nexport interface MarkerInfo {\n readonly hash: string;\n readonly bytes: number;\n readonly rule: string;\n readonly explanation: string;\n}\n\nexport type MarkerBuilder = (info: MarkerInfo) => string;\n\n/**\n * The version of the marker format itself, carried **in band** in every marker.\n *\n * The marker is the one part of smelt a *model* sees, and it goes into prompts.\n * Changing its shape changes model behaviour downstream and shows up as worse output\n * with no error anywhere — this project's signature failure mode, shipped as a patch\n * release. So the wire surface is frozen from 0.1 and treated as 1.0\n * (`CONTRIBUTING.md` § \"Two promises, not one\"), and a future format is *additive and\n * identifiable*: `smelt/v2` markers can coexist with `smelt/v1` ones, and a consumer\n * parsing markers can tell which it is holding. A format that changed silently would\n * be a substitution; this makes it a declaration.\n *\n * `test/guards/marker-format.test.ts` pins the rendered marker per version and fails if\n * the format moves without the version moving.\n */\nexport const MARKER_FORMAT_VERSION = 'v1';\n\n/**\n * The default marker.\n *\n * Its shape is the user-facing form of Laws 2 and 3, in one line the model reads:\n * *which format this is* (the version), *what was removed* (the explanation), *how\n * much* (the byte count), and *how to get it back* (the hash). Anything that cannot\n * fill in all of those is not allowed to be an elision.\n *\n * `<<…>>` rather than a Unicode bracket because it survives every tokenizer, terminal,\n * and diff tool without becoming three tokens of nothing.\n */\nexport const defaultMarker: MarkerBuilder = ({ explanation, bytes, hash }) =>\n `<<smelt/${MARKER_FORMAT_VERSION}: ${explanation} (${String(bytes)}B) — retrieve(\"${hash}\")>>`;\n\n/**\n * Line-comment leaders per language: the marker always lands as a comment in the\n * survivor's own syntax, because a bare marker line breaks the syntax of what remains\n * around it — in **every** grammar this was tested against, not just the indented ones.\n *\n * The failure classes, each verified by reparsing a bare-marker survivor:\n *\n * - **python** — significant indentation means a parse error does not stay local.\n * The ERROR node swallows the *neighbouring definitions too* — the survivor stops\n * being Python at all, not just at the marker line.\n * - **ruby** and **bash** — the marker *begins with* `<<`, which both languages read\n * as a heredoc operator. A bare marker line opens a heredoc whose terminator never\n * arrives, and everything after it — every kept declaration — is swallowed into a\n * string literal.\n * - **php** — `<<` is an operator here too: the kept function after a bare marker is\n * re-typed into an anonymous-function operand inside the marker's binary\n * expression. The kept declaration is no longer a declaration in the survivor.\n * - **kotlin**, **swift**, and the rest of the brace-delimited set (typescript, tsx,\n * javascript, rust, go, java, c, cpp, c_sharp) — the folk claim that braces keep a\n * parse error local is **empirically false**: reparsing each language's fixture\n * survivor with its own bundled grammar shows ERROR nodes spanning the kept\n * declarations (a C function's signature absorbed into an ERROR, fifteen cascading\n * ERRORs in swift, and so on). The survivor-reparse guard in\n * `test/guards/structural.test.ts` now asserts the opposite property for every\n * structural language: the survivor reparses with no new issues.\n *\n * Only `'unknown'` keeps the bare marker — lexical text has no syntax to break.\n *\n * This does **not** move the frozen wire surface. The `<<smelt/v1: … >>` core is\n * rendered by {@link defaultMarker}, byte-identical and still versioned in band; the\n * leader is part of the substituted marker text, so `outputRange` covers it and\n * reconstruction stays byte-exact. A comment leader in the survivor's own syntax is\n * the one wrapping that cannot change what a model reads out of the marker.\n *\n * A derived view: each leader is the `markerLeader` fact on the language's\n * {@link LanguageProfile} (`src/lang/`), collected here so marker construction keeps\n * one lookup table.\n */\nexport const MARKER_LINE_COMMENT_LEADERS: Readonly<Partial<Record<DetectedLanguage, string>>> =\n Object.fromEntries(\n Object.values(LANGUAGE_PROFILES)\n .filter((profile) => profile.markerLeader !== undefined)\n .map((profile) => [profile.id, profile.markerLeader]),\n );\n\n/**\n * The marker builder for a language: {@link defaultMarker}, wrapped in the language's\n * line-comment leader when {@link MARKER_LINE_COMMENT_LEADERS} names one — so a Python\n * survivor still parses as Python. Everything else gets `base` unchanged.\n */\nexport function markerForLanguage(\n language: DetectedLanguage,\n base: MarkerBuilder = defaultMarker,\n): MarkerBuilder {\n const leader = MARKER_LINE_COMMENT_LEADERS[language];\n if (leader === undefined) return base;\n return (info) => `${leader}${base(info)}`;\n}\n\n/**\n * A stand-in hash of the real length, so a marker can be priced before the cut that\n * would earn it exists. Marker cost depends on the hash's *length*, never its value.\n */\nconst PLACEHOLDER_HASH = '0'.repeat(HASH_LENGTH);\n\n/**\n * The one adapter behind the {@link MarkerPricing} seam.\n *\n * Marker cost is this module's fact: `applyPlan` renders the marker, so only this\n * module can price it without guessing. The pricing is built from the **exact builder\n * `applyPlan` will use** — the same resolution, in the same order: a caller-supplied\n * builder (`SmelterConfig.marker` / `ApplyOptions.marker`) wins wholesale, otherwise\n * the language's leader-wrapped default via {@link markerForLanguage}.\n *\n * The custom-builder leg is load-bearing, not a convenience: a caller who installs a\n * longer `MarkerBuilder` changes what every elision costs, and a planner still pricing\n * the *default* marker would keep planning elisions the real marker makes\n * unprofitable — cuts that grow the output, silently. Pricing with the builder's own\n * rendering closes that hole: `costBytes` measures the marker *that builder* would\n * emit, byte for byte.\n *\n * `createSmelter` (and through it, the CLI) calls this centrally, once per smelt call;\n * a caller driving `planLexical`/`planStructural` directly builds its own and puts it\n * on the {@link PlanInput}.\n */\nexport function markerPricing(\n language: DetectedLanguage = 'unknown',\n markerBuilder?: MarkerBuilder,\n): MarkerPricing {\n // The same resolution applyPlan performs: a supplied builder wins wholesale.\n const build = markerBuilder ?? markerForLanguage(language);\n return {\n costBytes: (reason, elidedBytes) =>\n Buffer.byteLength(\n build({\n hash: PLACEHOLDER_HASH,\n bytes: elidedBytes,\n rule: reason.rule,\n explanation: reason.explanation,\n }),\n 'utf8',\n ),\n };\n}\n\nexport interface ApplyOptions {\n /**\n * Overrides the marker builder. The default follows the *plan's* language —\n * {@link markerForLanguage} — so the documented composition\n * `planStructural → applyPlan` lands a `# `-led marker in python without the caller\n * wiring it, the same as `createSmelter` does. A bare {@link defaultMarker} in a\n * python survivor is exactly the parse-breaking failure the leader exists to prevent.\n */\n readonly marker?: MarkerBuilder;\n /** A consumer-supplied counter. See {@link Measure}; the budget stays in bytes. */\n readonly measure?: Measure;\n}\n\n/**\n * Turn a plan into text.\n *\n * This is the only function in smelt that removes anything, and it contains no\n * judgement at all: it validates the plan, stores every removed run, substitutes\n * markers, and records where each marker landed. All the deciding happens in a\n * {@link Planner}, which is why a plan can be reviewed before a byte moves.\n *\n * @throws {RangeOutOfBoundsError} if a range falls outside the input's UTF-8 bytes.\n * @throws {OverlappingElisionError} if two ranges overlap — applying both would\n * corrupt the output, and picking a winner would be a silent guess.\n */\nexport function applyPlan(\n text: string,\n plan: ElisionPlan,\n store: ElisionStore,\n options: ApplyOptions = {},\n): SmeltResult {\n const buildMarker = options.marker ?? markerForLanguage(plan.language);\n const input = Buffer.from(text, 'utf8');\n\n const ordered = plan.elisions.toSorted((a, b) => a.range.start - b.range.start);\n for (const { range } of ordered) assertInBounds(range, input.length);\n for (let i = 1; i < ordered.length; i += 1) {\n const previous = ordered[i - 1]!;\n const current = ordered[i]!;\n if (current.range.start < previous.range.end) {\n throw new OverlappingElisionError(\n `smelt: plan from \"${plan.planner}\" elides overlapping ranges ` +\n `[${previous.range.start},${previous.range.end}) and ` +\n `[${current.range.start},${current.range.end}). A plan must be a partition.`,\n );\n }\n }\n\n const pieces: Buffer[] = [];\n const applied: AppliedElision[] = [];\n let cursor = 0;\n let outputBytes = 0;\n\n for (const { range, reason } of ordered) {\n const kept = input.subarray(cursor, range.start);\n pieces.push(kept);\n outputBytes += kept.length;\n\n const removed = input.subarray(range.start, range.end);\n const removedText = removed.toString('utf8');\n const hash = store.put(removedText);\n const marker = buildMarker({\n hash,\n bytes: removed.length,\n rule: reason.rule,\n explanation: reason.explanation,\n });\n const markerBuffer = Buffer.from(marker, 'utf8');\n pieces.push(markerBuffer);\n\n applied.push({\n hash,\n range,\n outputRange: { start: outputBytes, end: outputBytes + markerBuffer.length },\n bytes: removed.length,\n reason,\n marker,\n });\n outputBytes += markerBuffer.length;\n cursor = range.end;\n }\n\n const tail = input.subarray(cursor);\n pieces.push(tail);\n outputBytes += tail.length;\n\n const output = Buffer.concat(pieces).toString('utf8');\n const measure = options.measure;\n\n return {\n text: output,\n inputBytes: input.length,\n outputBytes,\n planner: plan.planner,\n language: plan.language,\n elisions: applied,\n ...(measure === undefined\n ? {}\n : {\n measured: {\n measure: measure.id,\n unit: measure.unit,\n input: measure.count(text),\n output: measure.count(output),\n },\n }),\n };\n}\n\n/**\n * Put it all back. `reconstruct(smelt(x), store) === x`, byte for byte — this is Law 3\n * expressed as an executable equation, and `test/guards/reversibility.test.ts` asserts\n * it on every input the suite knows about.\n *\n * Reads through `peek`, **not** `retrieve`: `retrieveCalls` and the expansion rate\n * exist to count *the model asking for hidden material back* — the honest signal this\n * whole project sells. A caller reassembling the original (to diff it, to verify a\n * round trip, to write it to disk) is not that, and counting it would inflate the one\n * number that must never flatter. The guard in\n * `test/guards/expansion-counter.test.ts` pins this: reconstruction leaves every\n * counter exactly where it was.\n *\n * @throws {UnknownHashError} if the store no longer holds an elision's bytes.\n */\nexport function reconstruct(result: SmeltResult, store: ElisionStore): string {\n const output = Buffer.from(result.text, 'utf8');\n const ordered = result.elisions.toSorted((a, b) => a.outputRange.start - b.outputRange.start);\n const pieces: Buffer[] = [];\n let cursor = 0;\n\n for (const elision of ordered) {\n assertInBounds(elision.outputRange, output.length);\n const content = store.peek(elision.hash);\n if (content === undefined) throw new UnknownHashError(elision.hash);\n pieces.push(output.subarray(cursor, elision.outputRange.start));\n pieces.push(Buffer.from(content, 'utf8'));\n cursor = elision.outputRange.end;\n }\n pieces.push(output.subarray(cursor));\n\n return Buffer.concat(pieces).toString('utf8');\n}\n\nfunction assertInBounds(range: ByteRange, length: number): void {\n if (\n !Number.isInteger(range.start) ||\n !Number.isInteger(range.end) ||\n range.start < 0 ||\n range.end > length ||\n range.start >= range.end\n ) {\n throw new RangeOutOfBoundsError(\n `smelt: range [${range.start},${range.end}) is not a non-empty range inside ` +\n `${String(length)} bytes.`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"apply.js","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAsBvD;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAE1C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,aAAa,GAAkB,CAAC,EAAE,WAAW,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAC3E,WAAW,qBAAqB,KAAK,WAAW,KAAK,MAAM,CAAC,KAAK,CAAC,kBAAkB,IAAI,MAAM,CAAC;AAEjG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,MAAM,CAAC,MAAM,2BAA2B,GACtC,MAAM,CAAC,WAAW,CAChB,MAAM,CAAC,MAAM,CAAC,iBAAiB,CAAC;KAC7B,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC;KACvD,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,CACxD,CAAC;AAEJ;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,QAA0B,EAC1B,IAAI,GAAkB,aAAa;IAEnC,MAAM,MAAM,GAAG,2BAA2B,CAAC,QAAQ,CAAC,CAAC;IACrD,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACtC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,gBAAgB,GAAG,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAQ,GAAqB,SAAS,EACtC,aAA6B;IAE7B,6EAA6E;IAC7E,MAAM,KAAK,GAAG,aAAa,IAAI,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAC3D,OAAO;QACL,SAAS,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,CACjC,MAAM,CAAC,UAAU,CACf,KAAK,CAAC;YACJ,IAAI,EAAE,gBAAgB;YACtB,KAAK,EAAE,WAAW;YAClB,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,WAAW,EAAE,MAAM,CAAC,WAAW;SAChC,CAAC,EACF,MAAM,CACP;KACJ,CAAC;AACJ,CAAC;AAeD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,SAAS,CACvB,IAAY,EACZ,IAAiB,EACjB,KAAmB,EACnB,OAAO,GAAiB,EAAE;IAE1B,MAAM,WAAW,GAAG,OAAO,CAAC,MAAM,IAAI,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACvE,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAExC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAChF,KAAK,MAAM,EAAE,KAAK,EAAE,IAAI,OAAO;QAAE,cAAc,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC;QACjC,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;QAC5B,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;YAC7C,MAAM,IAAI,uBAAuB,CAC/B,qBAAqB,IAAI,CAAC,OAAO,8BAA8B;gBAC7D,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,IAAI,QAAQ,CAAC,KAAK,CAAC,GAAG,QAAQ;gBACtD,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,IAAI,OAAO,CAAC,KAAK,CAAC,GAAG,gCAAgC,CAC/E,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,OAAO,GAAqB,EAAE,CAAC;IACrC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,WAAW,GAAG,CAAC,CAAC;IAEpB,KAAK,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,OAAO,EAAE,CAAC;QAC/C,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClB,WAAW,IAAI,IAAI,CAAC,MAAM,CAAC;QAE3B,MAAM,OAAO,GAAG,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACvD,MAAM,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC7C,mFAAmF;QACnF,6EAA6E;QAC7E,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QAC5C,MAAM,MAAM,GAAG,WAAW,CAAC;YACzB,IAAI;YACJ,KAAK,EAAE,OAAO,CAAC,MAAM;YACrB,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,WAAW,EAAE,MAAM,CAAC,WAAW;SAChC,CAAC,CAAC;QACH,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjD,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAE1B,OAAO,CAAC,IAAI,CAAC;YACX,IAAI;YACJ,KAAK;YACL,WAAW,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,WAAW,GAAG,YAAY,CAAC,MAAM,EAAE;YAC3E,KAAK,EAAE,OAAO,CAAC,MAAM;YACrB,MAAM;YACN,MAAM;YACN,gFAAgF;YAChF,8EAA8E;YAC9E,sCAAsC;YACtC,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;SAC1C,CAAC,CAAC;QACH,WAAW,IAAI,YAAY,CAAC,MAAM,CAAC;QACnC,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC;IACrB,CAAC;IAED,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,WAAW,IAAI,IAAI,CAAC,MAAM,CAAC;IAE3B,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACtD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAEhC,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,UAAU,EAAE,KAAK,CAAC,MAAM;QACxB,WAAW;QACX,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,QAAQ,EAAE,OAAO;QACjB,GAAG,CAAC,OAAO,KAAK,SAAS;YACvB,CAAC,CAAC,EAAE;YACJ,CAAC,CAAC;gBACE,QAAQ,EAAE;oBACR,OAAO,EAAE,OAAO,CAAC,EAAE;oBACnB,IAAI,EAAE,OAAO,CAAC,IAAI;oBAClB,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC;oBAC1B,MAAM,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC;iBAC9B;aACF,CAAC;KACP,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,WAAW,CAAC,MAAmB,EAAE,KAAmB;IAClE,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAChD,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,GAAG,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IAC9F,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,KAAK,MAAM,OAAO,IAAI,OAAO,EAAE,CAAC;QAC9B,cAAc,CAAC,OAAO,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QACnD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACzC,IAAI,OAAO,KAAK,SAAS;YAAE,MAAM,IAAI,gBAAgB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACpE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC;QAChE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;QAC1C,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC;IACnC,CAAC;IACD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IAErC,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,cAAc,CAAC,KAAgB,EAAE,MAAc;IACtD,IACE,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,KAAK,CAAC;QAC9B,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC;QAC5B,KAAK,CAAC,KAAK,GAAG,CAAC;QACf,KAAK,CAAC,GAAG,GAAG,MAAM;QAClB,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,GAAG,EACxB,CAAC;QACD,MAAM,IAAI,qBAAqB,CAC7B,iBAAiB,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,GAAG,oCAAoC;YAC3E,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAC7B,CAAC;IACJ,CAAC;AACH,CAAC","sourcesContent":["import { OverlappingElisionError, RangeOutOfBoundsError, UnknownHashError } from './errors.ts';\nimport { HASH_LENGTH } from './hash.ts';\nimport { LANGUAGE_PROFILES } from './lang/registry.ts';\nimport type {\n AppliedElision,\n ByteRange,\n DetectedLanguage,\n ElisionPlan,\n ElisionStore,\n MarkerPricing,\n Measure,\n SmeltResult,\n} from './types.ts';\n\n/** Everything the marker text is allowed to depend on. */\nexport interface MarkerInfo {\n readonly hash: string;\n readonly bytes: number;\n readonly rule: string;\n readonly explanation: string;\n}\n\nexport type MarkerBuilder = (info: MarkerInfo) => string;\n\n/**\n * The version of the marker format itself, carried **in band** in every marker.\n *\n * The marker is the one part of smelt a *model* sees, and it goes into prompts.\n * Changing its shape changes model behaviour downstream and shows up as worse output\n * with no error anywhere — this project's signature failure mode, shipped as a patch\n * release. So the wire surface is frozen from 0.1 and treated as 1.0\n * (`CONTRIBUTING.md` § \"Two promises, not one\"), and a future format is *additive and\n * identifiable*: `smelt/v2` markers can coexist with `smelt/v1` ones, and a consumer\n * parsing markers can tell which it is holding. A format that changed silently would\n * be a substitution; this makes it a declaration.\n *\n * `test/guards/marker-format.test.ts` pins the rendered marker per version and fails if\n * the format moves without the version moving.\n */\nexport const MARKER_FORMAT_VERSION = 'v1';\n\n/**\n * The default marker.\n *\n * Its shape is the user-facing form of Laws 2 and 3, in one line the model reads:\n * *which format this is* (the version), *what was removed* (the explanation), *how\n * much* (the byte count), and *how to get it back* (the hash). Anything that cannot\n * fill in all of those is not allowed to be an elision.\n *\n * `<<…>>` rather than a Unicode bracket because it survives every tokenizer, terminal,\n * and diff tool without becoming three tokens of nothing.\n */\nexport const defaultMarker: MarkerBuilder = ({ explanation, bytes, hash }) =>\n `<<smelt/${MARKER_FORMAT_VERSION}: ${explanation} (${String(bytes)}B) — retrieve(\"${hash}\")>>`;\n\n/**\n * Line-comment leaders per language: the marker always lands as a comment in the\n * survivor's own syntax, because a bare marker line breaks the syntax of what remains\n * around it — in **every** grammar this was tested against, not just the indented ones.\n *\n * The failure classes, each verified by reparsing a bare-marker survivor:\n *\n * - **python** — significant indentation means a parse error does not stay local.\n * The ERROR node swallows the *neighbouring definitions too* — the survivor stops\n * being Python at all, not just at the marker line.\n * - **ruby** and **bash** — the marker *begins with* `<<`, which both languages read\n * as a heredoc operator. A bare marker line opens a heredoc whose terminator never\n * arrives, and everything after it — every kept declaration — is swallowed into a\n * string literal.\n * - **php** — `<<` is an operator here too: the kept function after a bare marker is\n * re-typed into an anonymous-function operand inside the marker's binary\n * expression. The kept declaration is no longer a declaration in the survivor.\n * - **kotlin**, **swift**, and the rest of the brace-delimited set (typescript, tsx,\n * javascript, rust, go, java, c, cpp, c_sharp) — the folk claim that braces keep a\n * parse error local is **empirically false**: reparsing each language's fixture\n * survivor with its own bundled grammar shows ERROR nodes spanning the kept\n * declarations (a C function's signature absorbed into an ERROR, fifteen cascading\n * ERRORs in swift, and so on). The survivor-reparse guard in\n * `test/guards/structural.test.ts` now asserts the opposite property for every\n * structural language: the survivor reparses with no new issues.\n *\n * Only `'unknown'` keeps the bare marker — lexical text has no syntax to break.\n *\n * This does **not** move the frozen wire surface. The `<<smelt/v1: … >>` core is\n * rendered by {@link defaultMarker}, byte-identical and still versioned in band; the\n * leader is part of the substituted marker text, so `outputRange` covers it and\n * reconstruction stays byte-exact. A comment leader in the survivor's own syntax is\n * the one wrapping that cannot change what a model reads out of the marker.\n *\n * A derived view: each leader is the `markerLeader` fact on the language's\n * {@link LanguageProfile} (`src/lang/`), collected here so marker construction keeps\n * one lookup table.\n */\nexport const MARKER_LINE_COMMENT_LEADERS: Readonly<Partial<Record<DetectedLanguage, string>>> =\n Object.fromEntries(\n Object.values(LANGUAGE_PROFILES)\n .filter((profile) => profile.markerLeader !== undefined)\n .map((profile) => [profile.id, profile.markerLeader]),\n );\n\n/**\n * The marker builder for a language: {@link defaultMarker}, wrapped in the language's\n * line-comment leader when {@link MARKER_LINE_COMMENT_LEADERS} names one — so a Python\n * survivor still parses as Python. Everything else gets `base` unchanged.\n */\nexport function markerForLanguage(\n language: DetectedLanguage,\n base: MarkerBuilder = defaultMarker,\n): MarkerBuilder {\n const leader = MARKER_LINE_COMMENT_LEADERS[language];\n if (leader === undefined) return base;\n return (info) => `${leader}${base(info)}`;\n}\n\n/**\n * A stand-in hash of the real length, so a marker can be priced before the cut that\n * would earn it exists. Marker cost depends on the hash's *length*, never its value.\n */\nconst PLACEHOLDER_HASH = '0'.repeat(HASH_LENGTH);\n\n/**\n * The one adapter behind the {@link MarkerPricing} seam.\n *\n * Marker cost is this module's fact: `applyPlan` renders the marker, so only this\n * module can price it without guessing. The pricing is built from the **exact builder\n * `applyPlan` will use** — the same resolution, in the same order: a caller-supplied\n * builder (`SmelterConfig.marker` / `ApplyOptions.marker`) wins wholesale, otherwise\n * the language's leader-wrapped default via {@link markerForLanguage}.\n *\n * The custom-builder leg is load-bearing, not a convenience: a caller who installs a\n * longer `MarkerBuilder` changes what every elision costs, and a planner still pricing\n * the *default* marker would keep planning elisions the real marker makes\n * unprofitable — cuts that grow the output, silently. Pricing with the builder's own\n * rendering closes that hole: `costBytes` measures the marker *that builder* would\n * emit, byte for byte.\n *\n * `createSmelter` (and through it, the CLI) calls this centrally, once per smelt call;\n * a caller driving `planLexical`/`planStructural` directly builds its own and puts it\n * on the {@link PlanInput}.\n */\nexport function markerPricing(\n language: DetectedLanguage = 'unknown',\n markerBuilder?: MarkerBuilder,\n): MarkerPricing {\n // The same resolution applyPlan performs: a supplied builder wins wholesale.\n const build = markerBuilder ?? markerForLanguage(language);\n return {\n costBytes: (reason, elidedBytes) =>\n Buffer.byteLength(\n build({\n hash: PLACEHOLDER_HASH,\n bytes: elidedBytes,\n rule: reason.rule,\n explanation: reason.explanation,\n }),\n 'utf8',\n ),\n };\n}\n\nexport interface ApplyOptions {\n /**\n * Overrides the marker builder. The default follows the *plan's* language —\n * {@link markerForLanguage} — so the documented composition\n * `planStructural → applyPlan` lands a `# `-led marker in python without the caller\n * wiring it, the same as `createSmelter` does. A bare {@link defaultMarker} in a\n * python survivor is exactly the parse-breaking failure the leader exists to prevent.\n */\n readonly marker?: MarkerBuilder;\n /** A consumer-supplied counter. See {@link Measure}; the budget stays in bytes. */\n readonly measure?: Measure;\n}\n\n/**\n * Turn a plan into text.\n *\n * This is the only function in smelt that removes anything, and it contains no\n * judgement at all: it validates the plan, stores every removed run, substitutes\n * markers, and records where each marker landed. All the deciding happens in a\n * {@link Planner}, which is why a plan can be reviewed before a byte moves.\n *\n * @throws {RangeOutOfBoundsError} if a range falls outside the input's UTF-8 bytes.\n * @throws {OverlappingElisionError} if two ranges overlap — applying both would\n * corrupt the output, and picking a winner would be a silent guess.\n */\nexport function applyPlan(\n text: string,\n plan: ElisionPlan,\n store: ElisionStore,\n options: ApplyOptions = {},\n): SmeltResult {\n const buildMarker = options.marker ?? markerForLanguage(plan.language);\n const input = Buffer.from(text, 'utf8');\n\n const ordered = plan.elisions.toSorted((a, b) => a.range.start - b.range.start);\n for (const { range } of ordered) assertInBounds(range, input.length);\n for (let i = 1; i < ordered.length; i += 1) {\n const previous = ordered[i - 1]!;\n const current = ordered[i]!;\n if (current.range.start < previous.range.end) {\n throw new OverlappingElisionError(\n `smelt: plan from \"${plan.planner}\" elides overlapping ranges ` +\n `[${previous.range.start},${previous.range.end}) and ` +\n `[${current.range.start},${current.range.end}). A plan must be a partition.`,\n );\n }\n }\n\n const pieces: Buffer[] = [];\n const applied: AppliedElision[] = [];\n let cursor = 0;\n let outputBytes = 0;\n\n for (const { range, reason, names } of ordered) {\n const kept = input.subarray(cursor, range.start);\n pieces.push(kept);\n outputBytes += kept.length;\n\n const removed = input.subarray(range.start, range.end);\n const removedText = removed.toString('utf8');\n // Attributed to its rule: this is the one place bytes leave the text, so it is the\n // one place the store learns which rule cut them — the ledger's only source.\n const hash = store.put(removedText, reason);\n const marker = buildMarker({\n hash,\n bytes: removed.length,\n rule: reason.rule,\n explanation: reason.explanation,\n });\n const markerBuffer = Buffer.from(marker, 'utf8');\n pieces.push(markerBuffer);\n\n applied.push({\n hash,\n range,\n outputRange: { start: outputBytes, end: outputBytes + markerBuffer.length },\n bytes: removed.length,\n reason,\n marker,\n // The outline rides beside the marker, never inside it: `buildMarker` above was\n // handed the reason and nothing else, so the wire surface and its priced cost\n // are the same with or without names.\n ...(names === undefined ? {} : { names }),\n });\n outputBytes += markerBuffer.length;\n cursor = range.end;\n }\n\n const tail = input.subarray(cursor);\n pieces.push(tail);\n outputBytes += tail.length;\n\n const output = Buffer.concat(pieces).toString('utf8');\n const measure = options.measure;\n\n return {\n text: output,\n inputBytes: input.length,\n outputBytes,\n planner: plan.planner,\n language: plan.language,\n elisions: applied,\n ...(measure === undefined\n ? {}\n : {\n measured: {\n measure: measure.id,\n unit: measure.unit,\n input: measure.count(text),\n output: measure.count(output),\n },\n }),\n };\n}\n\n/**\n * Put it all back. `reconstruct(smelt(x), store) === x`, byte for byte — this is Law 3\n * expressed as an executable equation, and `test/guards/reversibility.test.ts` asserts\n * it on every input the suite knows about.\n *\n * Reads through `peek`, **not** `retrieve`: `retrieveCalls` and the expansion rate\n * exist to count *the model asking for hidden material back* — the honest signal this\n * whole project sells. A caller reassembling the original (to diff it, to verify a\n * round trip, to write it to disk) is not that, and counting it would inflate the one\n * number that must never flatter. The guard in\n * `test/guards/expansion-counter.test.ts` pins this: reconstruction leaves every\n * counter exactly where it was.\n *\n * @throws {UnknownHashError} if the store no longer holds an elision's bytes.\n */\nexport function reconstruct(result: SmeltResult, store: ElisionStore): string {\n const output = Buffer.from(result.text, 'utf8');\n const ordered = result.elisions.toSorted((a, b) => a.outputRange.start - b.outputRange.start);\n const pieces: Buffer[] = [];\n let cursor = 0;\n\n for (const elision of ordered) {\n assertInBounds(elision.outputRange, output.length);\n const content = store.peek(elision.hash);\n if (content === undefined) throw new UnknownHashError(elision.hash);\n pieces.push(output.subarray(cursor, elision.outputRange.start));\n pieces.push(Buffer.from(content, 'utf8'));\n cursor = elision.outputRange.end;\n }\n pieces.push(output.subarray(cursor));\n\n return Buffer.concat(pieces).toString('utf8');\n}\n\nfunction assertInBounds(range: ByteRange, length: number): void {\n if (\n !Number.isInteger(range.start) ||\n !Number.isInteger(range.end) ||\n range.start < 0 ||\n range.end > length ||\n range.start >= range.end\n ) {\n throw new RangeOutOfBoundsError(\n `smelt: range [${range.start},${range.end}) is not a non-empty range inside ` +\n `${String(length)} bytes.`,\n );\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/cli/init.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAU/C;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,mDAAmD;AACnD,eAAO,MAAM,iBAAiB,qBAAqB,CAAC;AAEpD,+CAA+C;AAC/C,eAAO,MAAM,gBAAgB,oBAAoB,CAAC;AAElD,+FAA+F;AAC/F,MAAM,WAAW,MAAM;IACrB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AA8BD;;;;;;GAMG;AACH,wBAAsB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAoBzD;AAsID;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQjE;AAkVD,kFAAkF;AAClF,wBAAgB,iBAAiB,IAAI,MAAM,CA+B1C;AAED,iFAAiF;AACjF,wBAAgB,gBAAgB,IAAI,MAAM,CA6DzC","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nimport { DEFAULT_STRATEGY, STRATEGIES } from '../plan/planners.ts';\nimport type { Strategy } from '../plan/planners.ts';\nimport { STRUCTURAL_LANGUAGES } from '../plan/structural.ts';\nimport { SETUP_RECIPE } from '../setup/recipe.ts';\n\nimport { CLI_NAME } from './shell.ts';\nimport { wizardAsk } from './wizard.ts';\nimport type { AnswerStream } from './shell.ts';\nimport {\n CONFIG_FILE_NAME,\n CONFIG_VERSION,\n findConfigFile,\n parseConfig,\n renderConfig,\n} from './config.ts';\nimport type { SmeltConfig, SmeltConfigHooks, SmeltConfigStore } from './config.ts';\n\n/**\n * `smelt init` — the setup wizard.\n *\n * The same testability pattern as `run.ts`: the wizard is a pure function over an\n * input/output pair, so every flow — fresh run, back-navigation, re-run editing,\n * declined overwrites — runs in-process in tests, and `bin.ts` wires the real stdio.\n *\n * Three rules shape everything here:\n *\n * 1. **Nothing is written until the final confirm**, which lists exactly what will be\n * written or changed. A wizard that writes as it goes cannot be backed out of.\n * 2. **An existing file is never overwritten without an explicit per-file yes.** Not\n * a global \"overwrite all\", not a default — one question per existing file, and\n * anything but a literal `yes` skips it. Guarded by\n * `test/guards/init-wizard.test.ts`, with a mutation proving the guard goes red.\n * 3. **Every step accepts `back`.** A wizard you cannot reverse inside is a form. That\n * includes the directory question a fresh run asks inside a workspace: `back` from\n * the first step returns to it, keeping the answers given since.\n * 4. **Everything lands in one named directory.** A fresh run inside a workspace asks\n * which end of config discovery to write to (see `chooseDirectory`) and an edit run\n * writes beside the config it found; either way the directory is printed in the\n * \"About to write\" listing before the confirm, and nothing is ever written outside\n * it. A wizard that writes where the user cannot see is a wizard writing somewhere\n * they did not agree to.\n *\n * Law 4 note: the wizard's copy states what each choice *does*, never what it saves —\n * no percentages, no rates, no numbers smelt has not measured.\n */\n\n/** The generated measure-hook stub's file name. */\nexport const MEASURE_STUB_FILE = 'smelt.measure.ts';\n\n/** The generated reranker stub's file name. */\nexport const RERANK_STUB_FILE = 'smelt.rerank.ts';\n\n/** Where the wizard's bytes come from and go. Injected so `runInit` is testable in-process. */\nexport interface InitIo {\n /**\n * Interactive input — the real stdin in `bin.ts`, a scripted stream in tests.\n * Structural on purpose; see {@link AnswerStream}.\n */\n readonly input: AnswerStream;\n readonly output: (text: string) => void;\n /**\n * Where config discovery starts, and where a fresh run's files land — unless `cwd`\n * is inside a workspace and the user picks its root instead (`chooseDirectory`). An\n * edit run writes next to the discovered config, which may be an ancestor of `cwd`.\n * In every flow the \"About to write\" listing names the directory before anything is\n * written, and nothing is written outside it.\n */\n readonly cwd: string;\n}\n\n/** Everything the wizard decides. Pure data until the final confirm writes it. */\ninterface WizardChoices {\n budgetBytes: number | undefined;\n store: SmeltConfigStore;\n strategy: Strategy;\n /** Generate {@link MEASURE_STUB_FILE}? Never deletes an existing one. */\n measureStub: boolean;\n /** Generate {@link RERANK_STUB_FILE}? Never deletes an existing one. */\n rerankStub: boolean;\n /**\n * An existing config's `hooks` block, carried through verbatim. This wizard never\n * edits it — `smelt hooks install` owns those choices — but a re-run that silently\n * dropped it would be an edit the user never made.\n */\n hooks: SmeltConfigHooks | undefined;\n}\n\ntype StepOutcome = 'ok' | 'back';\n\ninterface PlannedWrite {\n readonly name: string;\n readonly path: string;\n readonly content: string;\n readonly exists: boolean;\n /** The file already holds exactly these bytes — nothing to write. */\n readonly unchanged: boolean;\n}\n\n/**\n * The wizard, start to finish. Returns an exit code (0 in every completed flow,\n * including \"declined to write anything\").\n *\n * @throws {CliUsageError} on a malformed existing config, or when input ends before\n * the wizard finishes — both are usage-shaped, and nothing further is written.\n */\nexport async function runInit(io: InitIo): Promise<number> {\n // `answerReader` — not `Readable.from` + readline — because the wizard must *let go*\n // of stdin when it is done. See the note on `answerReader` for the hang that shape\n // caused: files written, `Done.` printed, and the process never exiting.\n const wizard = wizardAsk(\n io.input,\n io.output,\n `${CLI_NAME} init: input ended before the wizard finished. ` +\n `Files already confirmed and written stay; nothing further was written.`,\n );\n const ask = wizard.ask;\n\n try {\n const existing = loadExisting(io.cwd);\n return existing === undefined\n ? await freshRun(io, ask)\n : await editRun(io, ask, existing.path, existing.config);\n } finally {\n await wizard.release();\n }\n}\n\nfunction loadExisting(cwd: string): { path: string; config: SmeltConfig } | undefined {\n const path = findConfigFile(cwd);\n if (path === undefined) return undefined;\n // Malformed is a loud usage error, not a silent fresh start: overwriting a config\n // the user wrote, because it had a typo, would be the wizard deciding for them.\n return { path, config: parseConfig(readFileSync(path, 'utf8'), path) };\n}\n\n// ---------------------------------------------------------------------------\n// The two flows\n// ---------------------------------------------------------------------------\n\ntype Asker = (prompt: string) => Promise<string>;\n\ninterface Step {\n readonly id: 'budget' | 'store' | 'strategy' | 'measure' | 'rerank';\n run(io: InitIo, ask: Asker, choices: WizardChoices, dir: string): Promise<StepOutcome>;\n}\n\nconst STEPS: readonly Step[] = [\n { id: 'budget', run: stepBudget },\n { id: 'store', run: stepStore },\n { id: 'strategy', run: stepStrategy },\n { id: 'measure', run: stepMeasure },\n { id: 'rerank', run: stepRerank },\n];\n\nasync function freshRun(io: InitIo, ask: Asker): Promise<number> {\n const choices: WizardChoices = {\n budgetBytes: undefined,\n store: { kind: 'memory' },\n strategy: DEFAULT_STRATEGY,\n measureStub: false,\n rerankStub: false,\n hooks: undefined,\n };\n\n // Outside a workspace there is no directory question, so the first step really is the\n // first thing asked. Inside one there is, and `back` from the first step returns to it\n // — with every answer so far carried along, because reversing is not restarting.\n const root = findWorkspaceRoot(io.cwd);\n for (;;) {\n const dir = root === undefined ? io.cwd : await chooseDirectory(io, ask, root);\n io.output(\n `${CLI_NAME} init — sets up ${CONFIG_FILE_NAME} in ${dir}.\\n` +\n `A run reads the nearest ${CONFIG_FILE_NAME}, walking UP from the directory it ` +\n `is run in, so this one is found from ${dir} and everything below it.\\n` +\n `Answer \\`back\\` at any step to return to the previous one. ` +\n `Nothing is written until you confirm at the end.\\n\\n`,\n );\n if ((await runSteps(io, ask, choices, dir, root !== undefined)) === 'done') return 0;\n }\n}\n\n/**\n * The step loop for one chosen directory: every step, then the confirm, with `back`\n * reversing all the way through both. Returns `'directory'` when `back` walks off the\n * front and there is a directory question to walk back into — `freshRun` re-asks it and\n * calls this again with the same {@link WizardChoices}.\n */\nasync function runSteps(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n canReopenDirectory: boolean,\n): Promise<'done' | 'directory'> {\n let index = 0;\n for (;;) {\n while (index < STEPS.length) {\n const outcome = await STEPS[index]!.run(io, ask, choices, dir);\n if (outcome !== 'back') {\n index += 1;\n } else if (index > 0) {\n index -= 1;\n } else if (canReopenDirectory) {\n return 'directory';\n } else {\n io.output(`This is the first step — there is nothing before it.\\n`);\n }\n }\n const verdict = await confirmAndWrite(io, ask, choices, dir);\n if (verdict !== 'back') return 'done';\n index = STEPS.length - 1;\n }\n}\n\n/**\n * Which directory this fresh run writes into — and the wizard's answer to the\n * monorepo trap.\n *\n * `smelt init` in `packages/web` used to write a config in `packages/web`, silently,\n * while discovery walks **up**: every run from the repo root then found no config and\n * used none, and the user had watched themselves set a budget. So when the working\n * directory sits inside a workspace (`pnpm-workspace.yaml`, or a `package.json` with a\n * `workspaces` field, in any ancestor), the wizard states the discovery rule and asks\n * which end of it to write to, defaulting to the root that covers the whole tree.\n *\n * The answer becomes the directory every later step, the \"About to write\" listing and\n * every `writeFileSync` uses — so the wizard still writes only inside the one directory\n * the user was shown and confirmed, here as much as anywhere else. Outside a workspace\n * there is nothing to choose and nothing is asked; the discovery rule is stated either\n * way, because \"where will runs look for this?\" is not a question a user should have to\n * infer from a path.\n *\n * Rule 3 holds here too: this is genuinely the first question, so `back` at it has\n * nowhere to go — but `back` from the first *step* comes back to it (see `freshRun`),\n * carrying the answers already given. The most consequential question in the wizard is\n * not the one you cannot reverse into.\n */\nasync function chooseDirectory(io: InitIo, ask: Asker, root: string): Promise<string> {\n io.output(\n `${CLI_NAME} init — ${io.cwd} is inside a workspace rooted at ${root}.\\n` +\n `A run reads the nearest ${CONFIG_FILE_NAME}, walking UP from the directory it is ` +\n `run in. A config written here is found from here and below; a run from ${root} ` +\n `would not find it. One at the workspace root is found from every package in it.\\n` +\n ` 1. ${root} (the workspace root)\\n` +\n ` 2. ${io.cwd} (here — this package only)\\n`,\n );\n for (;;) {\n const answer = await ask(`where should ${CONFIG_FILE_NAME} go? (1/2) [1]> `);\n if (answer === 'back') {\n io.output(`This is the first question — there is nothing before it.\\n`);\n continue;\n }\n const pick = answer === '' ? '1' : answer;\n if (pick === '1') return root;\n if (pick === '2') return io.cwd;\n io.output(`1 for the workspace root, 2 for here.\\n`);\n }\n}\n\n/**\n * The nearest **ancestor** of `cwd` that declares a workspace, or `undefined`.\n *\n * Ancestors only: a `cwd` that is itself the workspace root has nothing to choose\n * between, so it is asked nothing. Two markers, because those are the two the\n * ecosystem writes — pnpm's `pnpm-workspace.yaml`, and the `workspaces` field npm,\n * yarn and bun read out of `package.json`. Detection never reads a lockfile and never\n * shells out; a `package.json` it cannot parse claims nothing, so it is not a root.\n */\nexport function findWorkspaceRoot(cwd: string): string | undefined {\n let dir = resolve(cwd);\n for (;;) {\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n if (declaresWorkspace(dir)) return dir;\n }\n}\n\nfunction declaresWorkspace(dir: string): boolean {\n if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true;\n const manifest = join(dir, 'package.json');\n if (!existsSync(manifest)) return false;\n let workspaces: unknown;\n try {\n ({ workspaces } = JSON.parse(readFileSync(manifest, 'utf8')) as { workspaces?: unknown });\n } catch {\n return false; // a package.json smelt cannot read is not a claim about anything\n }\n return Array.isArray(workspaces) || (typeof workspaces === 'object' && workspaces !== null);\n}\n\nasync function editRun(\n io: InitIo,\n ask: Asker,\n configPath: string,\n config: SmeltConfig,\n): Promise<number> {\n const dir = dirname(configPath);\n const choices: WizardChoices = {\n budgetBytes: config.defaultBudgetBytes,\n store: config.store ?? { kind: 'memory' },\n strategy: config.strategy ?? DEFAULT_STRATEGY,\n measureStub: false,\n rerankStub: false,\n hooks: config.hooks,\n };\n\n io.output(\n `${CLI_NAME} init — ${CONFIG_FILE_NAME} already exists at ${configPath}.\\n` +\n `Change one setting at a time; nothing is written until you confirm.\\n`,\n );\n\n for (;;) {\n io.output(`\\nCurrent values:\\n${summary(choices, dir)}\\n`);\n const answer = await ask(\n `Change which setting? (budget / store / strategy / measure / rerank, ` +\n `\\`done\\` to review and confirm, \\`back\\` to leave without writing)\\n> `,\n );\n if (answer === 'back') {\n io.output(`Left as it was. Nothing has been written.\\n`);\n return 0;\n }\n if (answer === 'done') {\n const verdict = await confirmAndWrite(io, ask, choices, dir);\n if (verdict !== 'back') return 0;\n continue; // back from confirm returns to this menu\n }\n const step = STEPS.find((candidate) => candidate.id === answer);\n if (step === undefined) {\n io.output(`Not a setting: \"${answer}\". One of: budget, store, strategy, measure, rerank.\\n`);\n continue;\n }\n await step.run(io, ask, choices, dir); // its own `back` returns here\n }\n}\n\nfunction summary(choices: WizardChoices, dir: string): string {\n const stubLine = (file: string, generate: boolean): string => {\n if (generate) return `generate ${file}`;\n return existsSync(join(dir, file)) ? `${file} exists (kept as is)` : 'none';\n };\n return [\n ` budget: ${choices.budgetBytes === undefined ? '(not set)' : `${String(choices.budgetBytes)} bytes`}`,\n ` store: ${choices.store.kind === 'memory' ? 'memory' : `directory (${choices.store.path})`}`,\n ` strategy: ${choices.strategy}`,\n ` measure: ${stubLine(MEASURE_STUB_FILE, choices.measureStub)}`,\n ` rerank: ${stubLine(RERANK_STUB_FILE, choices.rerankStub)}`,\n ].join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// The steps. Each one accepts `back`.\n// ---------------------------------------------------------------------------\n\nasync function stepBudget(io: InitIo, ask: Asker, choices: WizardChoices): Promise<StepOutcome> {\n io.output(\n `\\nDefault byte budget — used when a \\`${CLI_NAME}\\` run omits --budget.\\n` +\n `Budgets are UTF-8 bytes, permanently; an explicit --budget always wins.\\n` +\n `There is no suggested number: the right budget depends on your traffic, and ` +\n `smelt does not invent numbers it has not measured.\\n`,\n );\n for (;;) {\n const current = choices.budgetBytes === undefined ? '' : ` [${String(choices.budgetBytes)}]`;\n const answer = await ask(`budget in bytes${current} (or back)> `);\n if (answer === 'back') return 'back';\n if (answer === '' && choices.budgetBytes !== undefined) return 'ok';\n if (/^\\d+$/.test(answer) && Number(answer) > 0) {\n choices.budgetBytes = Number(answer);\n return 'ok';\n }\n io.output(`A whole number of bytes greater than zero, e.g. 4000.\\n`);\n }\n}\n\nasync function stepStore(io: InitIo, ask: Asker, choices: WizardChoices): Promise<StepOutcome> {\n io.output(\n `\\nWhere elided bytes live. Every elision is reversible only while a store holds ` +\n `its bytes (Law 3):\\n` +\n ` 1. memory — per-process; retrievals do not survive the process\\n` +\n ` 2. directory — persistent, content-addressed, on disk; retrieval counters ` +\n `survive restarts\\n`,\n );\n for (;;) {\n const current = choices.store.kind === 'memory' ? '1' : '2';\n const answer = await ask(`store (1/2) [${current}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? current : answer;\n if (pick === '1') {\n choices.store = { kind: 'memory' };\n return 'ok';\n }\n if (pick === '2') {\n const previous =\n choices.store.kind === 'directory' ? choices.store.path : SETUP_RECIPE.store.defaultDir;\n const path = await ask(`store directory, relative to ${CONFIG_FILE_NAME} [${previous}]> `);\n if (path === 'back') continue; // back to the store choice, not out of the step\n choices.store = { kind: 'directory', path: path === '' ? previous : path };\n return 'ok';\n }\n io.output(`1 for memory, 2 for directory, or back.\\n`);\n }\n}\n\n/**\n * One sentence per strategy, for the wizard's menu — `Record<Strategy, string>`, so a\n * strategy added to the `PLANNERS` registry without a line here is a compile error\n * rather than an option the wizard silently never offers. The menu itself, its\n * numbering and the re-prompt are all rendered from {@link STRATEGIES}, which is why\n * the third choice cost one entry in this table.\n */\nconst STRATEGY_BLURB: Readonly<Record<Strategy, string>> = {\n lexical: 'line windows around your focus terms; works on any text',\n structural:\n `parses ${String(STRUCTURAL_LANGUAGES.length)} languages with bundled grammars ` +\n `(${STRUCTURAL_LANGUAGES.join(', ')}) and collapses siblings by name; refuses ` +\n `other languages rather than approximating`,\n auto:\n 'structural for those languages and lexical for everything else; the result ' +\n 'names whichever one actually ran',\n};\n\nasync function stepStrategy(io: InitIo, ask: Asker, choices: WizardChoices): Promise<StepOutcome> {\n const picks = STRATEGIES.map((_, index) => String(index + 1));\n io.output(\n `\\nDefault planner strategy — used when a run omits --strategy:\\n` +\n STRATEGIES.map(\n (name, index) => ` ${picks[index]!}. ${name.padEnd(12)}— ${STRATEGY_BLURB[name]}\\n`,\n ).join(''),\n );\n for (;;) {\n const current = picks[STRATEGIES.indexOf(choices.strategy)]!;\n const answer = await ask(`strategy (${picks.join('/')}) [${current}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? current : answer;\n const index = picks.indexOf(pick);\n if (index >= 0) {\n choices.strategy = STRATEGIES[index]!;\n return 'ok';\n }\n io.output(`${STRATEGIES.map((name, i) => `${picks[i]!} for ${name}`).join(', ')}, or back.\\n`);\n }\n}\n\nasync function stepMeasure(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n): Promise<StepOutcome> {\n const exists = existsSync(join(dir, MEASURE_STUB_FILE));\n io.output(\n `\\nMeasure hook — your own counter (a tokenizer, usually), so results carry a ` +\n `second, labelled number next to the byte counts. smelt ships none: a token ` +\n `count without its tokenizer named is not a measurement.\\n` +\n ` 1. none\\n` +\n ` 2. generate ${MEASURE_STUB_FILE}, a typed stub you fill in\\n` +\n (exists ? `(${MEASURE_STUB_FILE} already exists; it is never deleted from here.)\\n` : ``),\n );\n for (;;) {\n const answer = await ask(`measure (1/2) [${choices.measureStub ? '2' : '1'}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? (choices.measureStub ? '2' : '1') : answer;\n if (pick === '1' || pick === '2') {\n choices.measureStub = pick === '2';\n return 'ok';\n }\n io.output(`1 for none, 2 to generate the stub, or back.\\n`);\n }\n}\n\nasync function stepRerank(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n): Promise<StepOutcome> {\n const exists = existsSync(join(dir, RERANK_STUB_FILE));\n io.output(\n `\\nReranker — a RerankStage of your own. smelt never bundles one and never will: ` +\n `an outbound call must live in your code, under your key, visible in your own ` +\n `review (Law 1).\\n` +\n ` 1. none\\n` +\n ` 2. generate ${RERANK_STUB_FILE}, a typed stub with the HTTP call sketched as a TODO\\n` +\n (exists ? `(${RERANK_STUB_FILE} already exists; it is never deleted from here.)\\n` : ``),\n );\n for (;;) {\n const answer = await ask(`rerank (1/2) [${choices.rerankStub ? '2' : '1'}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? (choices.rerankStub ? '2' : '1') : answer;\n if (pick === '1' || pick === '2') {\n choices.rerankStub = pick === '2';\n return 'ok';\n }\n io.output(`1 for none, 2 to generate the stub, or back.\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// The confirm step: the only place anything is written\n// ---------------------------------------------------------------------------\n\nconst writeLabel = (write: PlannedWrite): string => {\n if (write.unchanged) return 'unchanged — nothing to write';\n return write.exists ? 'exists — will ask before overwriting' : 'new';\n};\n\nasync function confirmAndWrite(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n): Promise<'done' | 'back'> {\n if (choices.budgetBytes === undefined) {\n // Unreachable in the fresh flow (the budget step requires a number) but reachable\n // from an edit of a config that never had one — `defaultBudgetBytes` is optional,\n // so such a config parses fine. The wizard still insists: a confirm without a\n // budget would write a file this wizard just called incomplete. `back` here is a\n // real `back` — it returns to the caller, never falls forward into the confirm.\n io.output(`No budget is set yet — set one before confirming.\\n`);\n if ((await stepBudget(io, ask, choices)) === 'back') return 'back';\n }\n\n const writes = plannedWrites(choices, dir);\n io.output(\n `\\nAbout to write, into ${dir}:\\n` +\n writes.map((write) => ` ${write.name.padEnd(20)} (${writeLabel(write)})\\n`).join('') +\n `Nothing has been written yet.\\n`,\n );\n\n for (;;) {\n const answer = await ask(`confirm (yes / no / back)> `);\n if (answer === 'back') return 'back';\n if (answer === 'no') {\n io.output(`Nothing was written.\\n`);\n return 'done';\n }\n if (answer === 'yes') break;\n io.output(`yes to write, no to leave everything untouched, back to change a setting.\\n`);\n }\n\n for (const write of writes) {\n if (write.unchanged) {\n io.output(` ${write.name} — unchanged, not rewritten\\n`);\n continue;\n }\n if (write.exists) {\n // The per-file consent rule: one explicit question per existing file, and only\n // a literal `yes` overwrites. This is the line the mutation\n // `init-overwrite-without-consent` breaks to prove the guard can go red.\n const answer = await ask(` ${write.name} exists — overwrite it? (yes/no)> `);\n if (answer !== 'yes') {\n io.output(` skipped ${write.name} — the existing file was not touched\\n`);\n continue;\n }\n }\n writeFileSync(write.path, write.content);\n io.output(` wrote ${write.name}\\n`);\n }\n io.output(`Done.\\n`);\n io.output(\n `Also: \\`${CLI_NAME} hooks install\\` wires the smelt guard into agent-harness ` +\n `hooks (Claude Code, Codex, and more) — it detects installed harnesses and asks ` +\n `before writing anything.\\n`,\n );\n return 'done';\n}\n\nfunction plannedWrites(choices: WizardChoices, dir: string): readonly PlannedWrite[] {\n const plan = (name: string, content: string): PlannedWrite => {\n const path = join(dir, name);\n const exists = existsSync(path);\n const unchanged = exists && readFileSync(path, 'utf8') === content;\n return { name, path, content, exists, unchanged };\n };\n const writes = [plan(CONFIG_FILE_NAME, renderConfig(chosenConfig(choices)))];\n if (choices.measureStub) writes.push(plan(MEASURE_STUB_FILE, measureStubSource()));\n if (choices.rerankStub) writes.push(plan(RERANK_STUB_FILE, rerankStubSource()));\n return writes;\n}\n\n/**\n * The config the wizard's choices mean — the wizard's *policy*, handed to the one\n * writer in `config.ts`. `strategy` and `store` are always written: the wizard asked\n * about both, and a chosen value left out of the file would be a setting the user\n * watched themselves pick and never got.\n */\nfunction chosenConfig(choices: {\n readonly budgetBytes: number | undefined;\n readonly store: SmeltConfigStore;\n readonly strategy: Strategy;\n /** Carried through from an existing config; this wizard never edits it. */\n readonly hooks?: SmeltConfigHooks | undefined;\n}): SmeltConfig {\n return {\n smeltConfig: CONFIG_VERSION,\n ...(choices.budgetBytes === undefined ? {} : { defaultBudgetBytes: choices.budgetBytes }),\n strategy: choices.strategy,\n store: choices.store,\n ...(choices.hooks === undefined ? {} : { hooks: choices.hooks }),\n };\n}\n\n// ---------------------------------------------------------------------------\n// The generated stubs. String literals on purpose: smelt's own import graph must not\n// gain an HTTP client, and the zero-network guard's string-stripper ignores string\n// bodies — the sketched fetch below lives in the CONSUMER'S file, never in smelt's.\n//\n// The comment token between `from` and the package name in each stub's import line is\n// deliberate: the guard's import scanner reads raw source, so spelling that import\n// plainly inside this template would register as an edge in smelt's own graph — which\n// it is not. The comment keeps the generated file valid TypeScript while keeping this\n// data out of the guard's walk.\n// ---------------------------------------------------------------------------\n\n/** The `smelt.measure.ts` the wizard writes. Exported so tests can compile it. */\nexport function measureStubSource(): string {\n return `/**\n * Measure hook — generated by \\`smelt init\\`.\n *\n * smelt's budgets are UTF-8 bytes, permanently; this hook adds a second, labelled\n * number to every result in YOUR unit, counted by YOUR tokenizer. Both \\`id\\` and\n * \\`unit\\` are required: a count without the counter named is not a measurement.\n *\n * Wire it in, in your own code:\n *\n * const smelter = createSmelter({ defaultBudgetBytes: 8_000, measure });\n *\n * with createSmelter imported from your @smeltjs/core install and measure from here.\n */\nimport type { Measure } from /* your install */ '@smeltjs/core';\n\nexport const measure: Measure = {\n // TODO: name the counter that produces these numbers, e.g. 'tiktoken/o200k_base'.\n id: 'TODO/your-tokenizer',\n unit: 'tokens',\n count(text: string): number {\n // TODO: replace with your real tokenizer. It must be local and synchronous —\n // a count() that calls an API would make your process call an API on every smelt.\n // e.g. return encode(text).length;\n throw new Error(\n 'smelt.measure.ts: count() is not implemented yet — ' +\n 'fill it in with your tokenizer (length of input: ' + String(text.length) + ')',\n );\n },\n};\n`;\n}\n\n/** The `smelt.rerank.ts` the wizard writes. Exported so tests can compile it. */\nexport function rerankStubSource(): string {\n return `/**\n * Reranker stub — generated by \\`smelt init\\`.\n *\n * smelt itself makes zero network calls and never bundles a reranker (Law 1): the\n * moment one ships as a default, every consumer's source code leaves the machine and\n * they find out from a changelog, or never. So the outbound call lives HERE, in your\n * file, reading your env var, visible in your own review.\n *\n * Wire it into your own pipeline; smelt never calls this for you.\n */\nimport type { RerankCandidate, RerankedCandidate, RerankStage } from /* your install */ '@smeltjs/core';\n\n/** The env var YOUR code reads. Rename it to match your vendor. */\nconst API_KEY_ENV = 'RERANKER_API_KEY';\n\nexport const rerank: RerankStage = {\n id: 'my-reranker/v1',\n async rerank(\n candidates: readonly RerankCandidate[],\n query: string,\n ): Promise<readonly RerankedCandidate[]> {\n const apiKey = process.env[API_KEY_ENV];\n if (apiKey === undefined || apiKey === '') {\n throw new Error(\n 'smelt.rerank.ts: ' + API_KEY_ENV + ' is not set. This stage makes an outbound ' +\n 'HTTP call from YOUR code with YOUR key; without a key it refuses to pretend.',\n );\n }\n\n // TODO: the outbound call. Voyage AI is one example vendor; any reranker with an\n // HTTP API fits this shape:\n //\n // const response = await fetch('https://api.voyageai.com/v1/rerank', {\n // method: 'POST',\n // headers: {\n // 'content-type': 'application/json',\n // authorization: \\`Bearer \\${apiKey}\\`,\n // },\n // body: JSON.stringify({\n // query,\n // documents: candidates.map((candidate) => candidate.text),\n // }),\n // });\n // if (!response.ok) throw new Error('rerank failed: ' + String(response.status));\n // const body = (await response.json()) as {\n // data: { index: number; relevance_score: number }[];\n // };\n // return body.data.map(({ index, relevance_score }) => ({\n // ...candidates[index]!,\n // score: relevance_score,\n // }));\n //\n void candidates;\n void query;\n throw new Error(\n 'smelt.rerank.ts: implement the outbound call sketched above, then delete this throw.',\n );\n },\n};\n`;\n}\n"]}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/cli/init.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAU/C;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,mDAAmD;AACnD,eAAO,MAAM,iBAAiB,qBAAqB,CAAC;AAEpD,+CAA+C;AAC/C,eAAO,MAAM,gBAAgB,oBAAoB,CAAC;AAElD,+FAA+F;AAC/F,MAAM,WAAW,MAAM;IACrB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AA8BD;;;;;;GAMG;AACH,wBAAsB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAoBzD;AAsID;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQjE;AAoVD,kFAAkF;AAClF,wBAAgB,iBAAiB,IAAI,MAAM,CA+B1C;AAED,iFAAiF;AACjF,wBAAgB,gBAAgB,IAAI,MAAM,CA6DzC","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nimport { DEFAULT_STRATEGY, STRATEGIES } from '../plan/planners.ts';\nimport type { Strategy } from '../plan/planners.ts';\nimport { STRUCTURAL_LANGUAGES } from '../plan/structural.ts';\nimport { SETUP_RECIPE } from '../setup/recipe.ts';\n\nimport { CLI_NAME } from './shell.ts';\nimport { wizardAsk } from './wizard.ts';\nimport type { AnswerStream } from './shell.ts';\nimport {\n CONFIG_FILE_NAME,\n CONFIG_VERSION,\n findConfigFile,\n parseConfig,\n renderConfig,\n} from './config.ts';\nimport type { SmeltConfig, SmeltConfigHooks, SmeltConfigStore } from './config.ts';\n\n/**\n * `smelt init` — the setup wizard.\n *\n * The same testability pattern as `run.ts`: the wizard is a pure function over an\n * input/output pair, so every flow — fresh run, back-navigation, re-run editing,\n * declined overwrites — runs in-process in tests, and `bin.ts` wires the real stdio.\n *\n * Three rules shape everything here:\n *\n * 1. **Nothing is written until the final confirm**, which lists exactly what will be\n * written or changed. A wizard that writes as it goes cannot be backed out of.\n * 2. **An existing file is never overwritten without an explicit per-file yes.** Not\n * a global \"overwrite all\", not a default — one question per existing file, and\n * anything but a literal `yes` skips it. Guarded by\n * `test/guards/init-wizard.test.ts`, with a mutation proving the guard goes red.\n * 3. **Every step accepts `back`.** A wizard you cannot reverse inside is a form. That\n * includes the directory question a fresh run asks inside a workspace: `back` from\n * the first step returns to it, keeping the answers given since.\n * 4. **Everything lands in one named directory.** A fresh run inside a workspace asks\n * which end of config discovery to write to (see `chooseDirectory`) and an edit run\n * writes beside the config it found; either way the directory is printed in the\n * \"About to write\" listing before the confirm, and nothing is ever written outside\n * it. A wizard that writes where the user cannot see is a wizard writing somewhere\n * they did not agree to.\n *\n * Law 4 note: the wizard's copy states what each choice *does*, never what it saves —\n * no percentages, no rates, no numbers smelt has not measured.\n */\n\n/** The generated measure-hook stub's file name. */\nexport const MEASURE_STUB_FILE = 'smelt.measure.ts';\n\n/** The generated reranker stub's file name. */\nexport const RERANK_STUB_FILE = 'smelt.rerank.ts';\n\n/** Where the wizard's bytes come from and go. Injected so `runInit` is testable in-process. */\nexport interface InitIo {\n /**\n * Interactive input — the real stdin in `bin.ts`, a scripted stream in tests.\n * Structural on purpose; see {@link AnswerStream}.\n */\n readonly input: AnswerStream;\n readonly output: (text: string) => void;\n /**\n * Where config discovery starts, and where a fresh run's files land — unless `cwd`\n * is inside a workspace and the user picks its root instead (`chooseDirectory`). An\n * edit run writes next to the discovered config, which may be an ancestor of `cwd`.\n * In every flow the \"About to write\" listing names the directory before anything is\n * written, and nothing is written outside it.\n */\n readonly cwd: string;\n}\n\n/** Everything the wizard decides. Pure data until the final confirm writes it. */\ninterface WizardChoices {\n budgetBytes: number | undefined;\n store: SmeltConfigStore;\n strategy: Strategy;\n /** Generate {@link MEASURE_STUB_FILE}? Never deletes an existing one. */\n measureStub: boolean;\n /** Generate {@link RERANK_STUB_FILE}? Never deletes an existing one. */\n rerankStub: boolean;\n /**\n * An existing config's `hooks` block, carried through verbatim. This wizard never\n * edits it — `smelt hooks install` owns those choices — but a re-run that silently\n * dropped it would be an edit the user never made.\n */\n hooks: SmeltConfigHooks | undefined;\n}\n\ntype StepOutcome = 'ok' | 'back';\n\ninterface PlannedWrite {\n readonly name: string;\n readonly path: string;\n readonly content: string;\n readonly exists: boolean;\n /** The file already holds exactly these bytes — nothing to write. */\n readonly unchanged: boolean;\n}\n\n/**\n * The wizard, start to finish. Returns an exit code (0 in every completed flow,\n * including \"declined to write anything\").\n *\n * @throws {CliUsageError} on a malformed existing config, or when input ends before\n * the wizard finishes — both are usage-shaped, and nothing further is written.\n */\nexport async function runInit(io: InitIo): Promise<number> {\n // `answerReader` — not `Readable.from` + readline — because the wizard must *let go*\n // of stdin when it is done. See the note on `answerReader` for the hang that shape\n // caused: files written, `Done.` printed, and the process never exiting.\n const wizard = wizardAsk(\n io.input,\n io.output,\n `${CLI_NAME} init: input ended before the wizard finished. ` +\n `Files already confirmed and written stay; nothing further was written.`,\n );\n const ask = wizard.ask;\n\n try {\n const existing = loadExisting(io.cwd);\n return existing === undefined\n ? await freshRun(io, ask)\n : await editRun(io, ask, existing.path, existing.config);\n } finally {\n await wizard.release();\n }\n}\n\nfunction loadExisting(cwd: string): { path: string; config: SmeltConfig } | undefined {\n const path = findConfigFile(cwd);\n if (path === undefined) return undefined;\n // Malformed is a loud usage error, not a silent fresh start: overwriting a config\n // the user wrote, because it had a typo, would be the wizard deciding for them.\n return { path, config: parseConfig(readFileSync(path, 'utf8'), path) };\n}\n\n// ---------------------------------------------------------------------------\n// The two flows\n// ---------------------------------------------------------------------------\n\ntype Asker = (prompt: string) => Promise<string>;\n\ninterface Step {\n readonly id: 'budget' | 'store' | 'strategy' | 'measure' | 'rerank';\n run(io: InitIo, ask: Asker, choices: WizardChoices, dir: string): Promise<StepOutcome>;\n}\n\nconst STEPS: readonly Step[] = [\n { id: 'budget', run: stepBudget },\n { id: 'store', run: stepStore },\n { id: 'strategy', run: stepStrategy },\n { id: 'measure', run: stepMeasure },\n { id: 'rerank', run: stepRerank },\n];\n\nasync function freshRun(io: InitIo, ask: Asker): Promise<number> {\n const choices: WizardChoices = {\n budgetBytes: undefined,\n store: { kind: 'memory' },\n strategy: DEFAULT_STRATEGY,\n measureStub: false,\n rerankStub: false,\n hooks: undefined,\n };\n\n // Outside a workspace there is no directory question, so the first step really is the\n // first thing asked. Inside one there is, and `back` from the first step returns to it\n // — with every answer so far carried along, because reversing is not restarting.\n const root = findWorkspaceRoot(io.cwd);\n for (;;) {\n const dir = root === undefined ? io.cwd : await chooseDirectory(io, ask, root);\n io.output(\n `${CLI_NAME} init — sets up ${CONFIG_FILE_NAME} in ${dir}.\\n` +\n `A run reads the nearest ${CONFIG_FILE_NAME}, walking UP from the directory it ` +\n `is run in, so this one is found from ${dir} and everything below it.\\n` +\n `Answer \\`back\\` at any step to return to the previous one. ` +\n `Nothing is written until you confirm at the end.\\n\\n`,\n );\n if ((await runSteps(io, ask, choices, dir, root !== undefined)) === 'done') return 0;\n }\n}\n\n/**\n * The step loop for one chosen directory: every step, then the confirm, with `back`\n * reversing all the way through both. Returns `'directory'` when `back` walks off the\n * front and there is a directory question to walk back into — `freshRun` re-asks it and\n * calls this again with the same {@link WizardChoices}.\n */\nasync function runSteps(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n canReopenDirectory: boolean,\n): Promise<'done' | 'directory'> {\n let index = 0;\n for (;;) {\n while (index < STEPS.length) {\n const outcome = await STEPS[index]!.run(io, ask, choices, dir);\n if (outcome !== 'back') {\n index += 1;\n } else if (index > 0) {\n index -= 1;\n } else if (canReopenDirectory) {\n return 'directory';\n } else {\n io.output(`This is the first step — there is nothing before it.\\n`);\n }\n }\n const verdict = await confirmAndWrite(io, ask, choices, dir);\n if (verdict !== 'back') return 'done';\n index = STEPS.length - 1;\n }\n}\n\n/**\n * Which directory this fresh run writes into — and the wizard's answer to the\n * monorepo trap.\n *\n * `smelt init` in `packages/web` used to write a config in `packages/web`, silently,\n * while discovery walks **up**: every run from the repo root then found no config and\n * used none, and the user had watched themselves set a budget. So when the working\n * directory sits inside a workspace (`pnpm-workspace.yaml`, or a `package.json` with a\n * `workspaces` field, in any ancestor), the wizard states the discovery rule and asks\n * which end of it to write to, defaulting to the root that covers the whole tree.\n *\n * The answer becomes the directory every later step, the \"About to write\" listing and\n * every `writeFileSync` uses — so the wizard still writes only inside the one directory\n * the user was shown and confirmed, here as much as anywhere else. Outside a workspace\n * there is nothing to choose and nothing is asked; the discovery rule is stated either\n * way, because \"where will runs look for this?\" is not a question a user should have to\n * infer from a path.\n *\n * Rule 3 holds here too: this is genuinely the first question, so `back` at it has\n * nowhere to go — but `back` from the first *step* comes back to it (see `freshRun`),\n * carrying the answers already given. The most consequential question in the wizard is\n * not the one you cannot reverse into.\n */\nasync function chooseDirectory(io: InitIo, ask: Asker, root: string): Promise<string> {\n io.output(\n `${CLI_NAME} init — ${io.cwd} is inside a workspace rooted at ${root}.\\n` +\n `A run reads the nearest ${CONFIG_FILE_NAME}, walking UP from the directory it is ` +\n `run in. A config written here is found from here and below; a run from ${root} ` +\n `would not find it. One at the workspace root is found from every package in it.\\n` +\n ` 1. ${root} (the workspace root)\\n` +\n ` 2. ${io.cwd} (here — this package only)\\n`,\n );\n for (;;) {\n const answer = await ask(`where should ${CONFIG_FILE_NAME} go? (1/2) [1]> `);\n if (answer === 'back') {\n io.output(`This is the first question — there is nothing before it.\\n`);\n continue;\n }\n const pick = answer === '' ? '1' : answer;\n if (pick === '1') return root;\n if (pick === '2') return io.cwd;\n io.output(`1 for the workspace root, 2 for here.\\n`);\n }\n}\n\n/**\n * The nearest **ancestor** of `cwd` that declares a workspace, or `undefined`.\n *\n * Ancestors only: a `cwd` that is itself the workspace root has nothing to choose\n * between, so it is asked nothing. Two markers, because those are the two the\n * ecosystem writes — pnpm's `pnpm-workspace.yaml`, and the `workspaces` field npm,\n * yarn and bun read out of `package.json`. Detection never reads a lockfile and never\n * shells out; a `package.json` it cannot parse claims nothing, so it is not a root.\n */\nexport function findWorkspaceRoot(cwd: string): string | undefined {\n let dir = resolve(cwd);\n for (;;) {\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n if (declaresWorkspace(dir)) return dir;\n }\n}\n\nfunction declaresWorkspace(dir: string): boolean {\n if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true;\n const manifest = join(dir, 'package.json');\n if (!existsSync(manifest)) return false;\n let workspaces: unknown;\n try {\n ({ workspaces } = JSON.parse(readFileSync(manifest, 'utf8')) as { workspaces?: unknown });\n } catch {\n return false; // a package.json smelt cannot read is not a claim about anything\n }\n return Array.isArray(workspaces) || (typeof workspaces === 'object' && workspaces !== null);\n}\n\nasync function editRun(\n io: InitIo,\n ask: Asker,\n configPath: string,\n config: SmeltConfig,\n): Promise<number> {\n const dir = dirname(configPath);\n const choices: WizardChoices = {\n budgetBytes: config.defaultBudgetBytes,\n store: config.store ?? { kind: 'memory' },\n strategy: config.strategy ?? DEFAULT_STRATEGY,\n measureStub: false,\n rerankStub: false,\n hooks: config.hooks,\n };\n\n io.output(\n `${CLI_NAME} init — ${CONFIG_FILE_NAME} already exists at ${configPath}.\\n` +\n `Change one setting at a time; nothing is written until you confirm.\\n`,\n );\n\n for (;;) {\n io.output(`\\nCurrent values:\\n${summary(choices, dir)}\\n`);\n const answer = await ask(\n `Change which setting? (budget / store / strategy / measure / rerank, ` +\n `\\`done\\` to review and confirm, \\`back\\` to leave without writing)\\n> `,\n );\n if (answer === 'back') {\n io.output(`Left as it was. Nothing has been written.\\n`);\n return 0;\n }\n if (answer === 'done') {\n const verdict = await confirmAndWrite(io, ask, choices, dir);\n if (verdict !== 'back') return 0;\n continue; // back from confirm returns to this menu\n }\n const step = STEPS.find((candidate) => candidate.id === answer);\n if (step === undefined) {\n io.output(`Not a setting: \"${answer}\". One of: budget, store, strategy, measure, rerank.\\n`);\n continue;\n }\n await step.run(io, ask, choices, dir); // its own `back` returns here\n }\n}\n\nfunction summary(choices: WizardChoices, dir: string): string {\n const stubLine = (file: string, generate: boolean): string => {\n if (generate) return `generate ${file}`;\n return existsSync(join(dir, file)) ? `${file} exists (kept as is)` : 'none';\n };\n return [\n ` budget: ${choices.budgetBytes === undefined ? '(not set)' : `${String(choices.budgetBytes)} bytes`}`,\n ` store: ${choices.store.kind === 'memory' ? 'memory' : `directory (${choices.store.path})`}`,\n ` strategy: ${choices.strategy}`,\n ` measure: ${stubLine(MEASURE_STUB_FILE, choices.measureStub)}`,\n ` rerank: ${stubLine(RERANK_STUB_FILE, choices.rerankStub)}`,\n ].join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// The steps. Each one accepts `back`.\n// ---------------------------------------------------------------------------\n\nasync function stepBudget(io: InitIo, ask: Asker, choices: WizardChoices): Promise<StepOutcome> {\n io.output(\n `\\nDefault byte budget — used when a \\`${CLI_NAME}\\` run omits --budget.\\n` +\n `Budgets are UTF-8 bytes, permanently; an explicit --budget always wins.\\n` +\n `There is no suggested number: the right budget depends on your traffic, and ` +\n `smelt does not invent numbers it has not measured.\\n`,\n );\n for (;;) {\n const current = choices.budgetBytes === undefined ? '' : ` [${String(choices.budgetBytes)}]`;\n const answer = await ask(`budget in bytes${current} (or back)> `);\n if (answer === 'back') return 'back';\n if (answer === '' && choices.budgetBytes !== undefined) return 'ok';\n if (/^\\d+$/.test(answer) && Number(answer) > 0) {\n choices.budgetBytes = Number(answer);\n return 'ok';\n }\n io.output(`A whole number of bytes greater than zero, e.g. 4000.\\n`);\n }\n}\n\nasync function stepStore(io: InitIo, ask: Asker, choices: WizardChoices): Promise<StepOutcome> {\n io.output(\n `\\nWhere elided bytes live. Every elision is reversible only while a store holds ` +\n `its bytes (Law 3):\\n` +\n ` 1. memory — per-process; retrievals do not survive the process\\n` +\n ` 2. directory — persistent, content-addressed, on disk; retrieval counters ` +\n `survive restarts\\n`,\n );\n for (;;) {\n const current = choices.store.kind === 'memory' ? '1' : '2';\n const answer = await ask(`store (1/2) [${current}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? current : answer;\n if (pick === '1') {\n choices.store = { kind: 'memory' };\n return 'ok';\n }\n if (pick === '2') {\n const previous =\n choices.store.kind === 'directory' ? choices.store.path : SETUP_RECIPE.store.defaultDir;\n const path = await ask(`store directory, relative to ${CONFIG_FILE_NAME} [${previous}]> `);\n if (path === 'back') continue; // back to the store choice, not out of the step\n choices.store = { kind: 'directory', path: path === '' ? previous : path };\n return 'ok';\n }\n io.output(`1 for memory, 2 for directory, or back.\\n`);\n }\n}\n\n/**\n * One sentence per strategy, for the wizard's menu — `Record<Strategy, string>`, so a\n * strategy added to the `PLANNERS` registry without a line here is a compile error\n * rather than an option the wizard silently never offers. The menu itself, its\n * numbering and the re-prompt are all rendered from {@link STRATEGIES}, which is why\n * the third choice cost one entry in this table.\n */\nconst STRATEGY_BLURB: Readonly<Record<Strategy, string>> = {\n lexical: 'line windows around your focus terms; works on any text',\n structural:\n `parses ${String(STRUCTURAL_LANGUAGES.length)} languages with bundled grammars ` +\n `(${STRUCTURAL_LANGUAGES.join(', ')}) and collapses siblings by name; refuses ` +\n `other languages rather than approximating`,\n auto:\n 'content kind first (json, diff), then structural for those languages and ' +\n 'lexical for everything else; the result names whichever one actually ran',\n json: 'members and elements as units, for JSON tool results; refuses non-JSON',\n diff: 'files and hunks as units, for unified diffs; refuses anything else',\n};\n\nasync function stepStrategy(io: InitIo, ask: Asker, choices: WizardChoices): Promise<StepOutcome> {\n const picks = STRATEGIES.map((_, index) => String(index + 1));\n io.output(\n `\\nDefault planner strategy — used when a run omits --strategy:\\n` +\n STRATEGIES.map(\n (name, index) => ` ${picks[index]!}. ${name.padEnd(12)}— ${STRATEGY_BLURB[name]}\\n`,\n ).join(''),\n );\n for (;;) {\n const current = picks[STRATEGIES.indexOf(choices.strategy)]!;\n const answer = await ask(`strategy (${picks.join('/')}) [${current}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? current : answer;\n const index = picks.indexOf(pick);\n if (index >= 0) {\n choices.strategy = STRATEGIES[index]!;\n return 'ok';\n }\n io.output(`${STRATEGIES.map((name, i) => `${picks[i]!} for ${name}`).join(', ')}, or back.\\n`);\n }\n}\n\nasync function stepMeasure(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n): Promise<StepOutcome> {\n const exists = existsSync(join(dir, MEASURE_STUB_FILE));\n io.output(\n `\\nMeasure hook — your own counter (a tokenizer, usually), so results carry a ` +\n `second, labelled number next to the byte counts. smelt ships none: a token ` +\n `count without its tokenizer named is not a measurement.\\n` +\n ` 1. none\\n` +\n ` 2. generate ${MEASURE_STUB_FILE}, a typed stub you fill in\\n` +\n (exists ? `(${MEASURE_STUB_FILE} already exists; it is never deleted from here.)\\n` : ``),\n );\n for (;;) {\n const answer = await ask(`measure (1/2) [${choices.measureStub ? '2' : '1'}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? (choices.measureStub ? '2' : '1') : answer;\n if (pick === '1' || pick === '2') {\n choices.measureStub = pick === '2';\n return 'ok';\n }\n io.output(`1 for none, 2 to generate the stub, or back.\\n`);\n }\n}\n\nasync function stepRerank(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n): Promise<StepOutcome> {\n const exists = existsSync(join(dir, RERANK_STUB_FILE));\n io.output(\n `\\nReranker — a RerankStage of your own. smelt never bundles one and never will: ` +\n `an outbound call must live in your code, under your key, visible in your own ` +\n `review (Law 1).\\n` +\n ` 1. none\\n` +\n ` 2. generate ${RERANK_STUB_FILE}, a typed stub with the HTTP call sketched as a TODO\\n` +\n (exists ? `(${RERANK_STUB_FILE} already exists; it is never deleted from here.)\\n` : ``),\n );\n for (;;) {\n const answer = await ask(`rerank (1/2) [${choices.rerankStub ? '2' : '1'}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? (choices.rerankStub ? '2' : '1') : answer;\n if (pick === '1' || pick === '2') {\n choices.rerankStub = pick === '2';\n return 'ok';\n }\n io.output(`1 for none, 2 to generate the stub, or back.\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// The confirm step: the only place anything is written\n// ---------------------------------------------------------------------------\n\nconst writeLabel = (write: PlannedWrite): string => {\n if (write.unchanged) return 'unchanged — nothing to write';\n return write.exists ? 'exists — will ask before overwriting' : 'new';\n};\n\nasync function confirmAndWrite(\n io: InitIo,\n ask: Asker,\n choices: WizardChoices,\n dir: string,\n): Promise<'done' | 'back'> {\n if (choices.budgetBytes === undefined) {\n // Unreachable in the fresh flow (the budget step requires a number) but reachable\n // from an edit of a config that never had one — `defaultBudgetBytes` is optional,\n // so such a config parses fine. The wizard still insists: a confirm without a\n // budget would write a file this wizard just called incomplete. `back` here is a\n // real `back` — it returns to the caller, never falls forward into the confirm.\n io.output(`No budget is set yet — set one before confirming.\\n`);\n if ((await stepBudget(io, ask, choices)) === 'back') return 'back';\n }\n\n const writes = plannedWrites(choices, dir);\n io.output(\n `\\nAbout to write, into ${dir}:\\n` +\n writes.map((write) => ` ${write.name.padEnd(20)} (${writeLabel(write)})\\n`).join('') +\n `Nothing has been written yet.\\n`,\n );\n\n for (;;) {\n const answer = await ask(`confirm (yes / no / back)> `);\n if (answer === 'back') return 'back';\n if (answer === 'no') {\n io.output(`Nothing was written.\\n`);\n return 'done';\n }\n if (answer === 'yes') break;\n io.output(`yes to write, no to leave everything untouched, back to change a setting.\\n`);\n }\n\n for (const write of writes) {\n if (write.unchanged) {\n io.output(` ${write.name} — unchanged, not rewritten\\n`);\n continue;\n }\n if (write.exists) {\n // The per-file consent rule: one explicit question per existing file, and only\n // a literal `yes` overwrites. This is the line the mutation\n // `init-overwrite-without-consent` breaks to prove the guard can go red.\n const answer = await ask(` ${write.name} exists — overwrite it? (yes/no)> `);\n if (answer !== 'yes') {\n io.output(` skipped ${write.name} — the existing file was not touched\\n`);\n continue;\n }\n }\n writeFileSync(write.path, write.content);\n io.output(` wrote ${write.name}\\n`);\n }\n io.output(`Done.\\n`);\n io.output(\n `Also: \\`${CLI_NAME} hooks install\\` wires the smelt guard into agent-harness ` +\n `hooks (Claude Code, Codex, and more) — it detects installed harnesses and asks ` +\n `before writing anything.\\n`,\n );\n return 'done';\n}\n\nfunction plannedWrites(choices: WizardChoices, dir: string): readonly PlannedWrite[] {\n const plan = (name: string, content: string): PlannedWrite => {\n const path = join(dir, name);\n const exists = existsSync(path);\n const unchanged = exists && readFileSync(path, 'utf8') === content;\n return { name, path, content, exists, unchanged };\n };\n const writes = [plan(CONFIG_FILE_NAME, renderConfig(chosenConfig(choices)))];\n if (choices.measureStub) writes.push(plan(MEASURE_STUB_FILE, measureStubSource()));\n if (choices.rerankStub) writes.push(plan(RERANK_STUB_FILE, rerankStubSource()));\n return writes;\n}\n\n/**\n * The config the wizard's choices mean — the wizard's *policy*, handed to the one\n * writer in `config.ts`. `strategy` and `store` are always written: the wizard asked\n * about both, and a chosen value left out of the file would be a setting the user\n * watched themselves pick and never got.\n */\nfunction chosenConfig(choices: {\n readonly budgetBytes: number | undefined;\n readonly store: SmeltConfigStore;\n readonly strategy: Strategy;\n /** Carried through from an existing config; this wizard never edits it. */\n readonly hooks?: SmeltConfigHooks | undefined;\n}): SmeltConfig {\n return {\n smeltConfig: CONFIG_VERSION,\n ...(choices.budgetBytes === undefined ? {} : { defaultBudgetBytes: choices.budgetBytes }),\n strategy: choices.strategy,\n store: choices.store,\n ...(choices.hooks === undefined ? {} : { hooks: choices.hooks }),\n };\n}\n\n// ---------------------------------------------------------------------------\n// The generated stubs. String literals on purpose: smelt's own import graph must not\n// gain an HTTP client, and the zero-network guard's string-stripper ignores string\n// bodies — the sketched fetch below lives in the CONSUMER'S file, never in smelt's.\n//\n// The comment token between `from` and the package name in each stub's import line is\n// deliberate: the guard's import scanner reads raw source, so spelling that import\n// plainly inside this template would register as an edge in smelt's own graph — which\n// it is not. The comment keeps the generated file valid TypeScript while keeping this\n// data out of the guard's walk.\n// ---------------------------------------------------------------------------\n\n/** The `smelt.measure.ts` the wizard writes. Exported so tests can compile it. */\nexport function measureStubSource(): string {\n return `/**\n * Measure hook — generated by \\`smelt init\\`.\n *\n * smelt's budgets are UTF-8 bytes, permanently; this hook adds a second, labelled\n * number to every result in YOUR unit, counted by YOUR tokenizer. Both \\`id\\` and\n * \\`unit\\` are required: a count without the counter named is not a measurement.\n *\n * Wire it in, in your own code:\n *\n * const smelter = createSmelter({ defaultBudgetBytes: 8_000, measure });\n *\n * with createSmelter imported from your @smeltjs/core install and measure from here.\n */\nimport type { Measure } from /* your install */ '@smeltjs/core';\n\nexport const measure: Measure = {\n // TODO: name the counter that produces these numbers, e.g. 'tiktoken/o200k_base'.\n id: 'TODO/your-tokenizer',\n unit: 'tokens',\n count(text: string): number {\n // TODO: replace with your real tokenizer. It must be local and synchronous —\n // a count() that calls an API would make your process call an API on every smelt.\n // e.g. return encode(text).length;\n throw new Error(\n 'smelt.measure.ts: count() is not implemented yet — ' +\n 'fill it in with your tokenizer (length of input: ' + String(text.length) + ')',\n );\n },\n};\n`;\n}\n\n/** The `smelt.rerank.ts` the wizard writes. Exported so tests can compile it. */\nexport function rerankStubSource(): string {\n return `/**\n * Reranker stub — generated by \\`smelt init\\`.\n *\n * smelt itself makes zero network calls and never bundles a reranker (Law 1): the\n * moment one ships as a default, every consumer's source code leaves the machine and\n * they find out from a changelog, or never. So the outbound call lives HERE, in your\n * file, reading your env var, visible in your own review.\n *\n * Wire it into your own pipeline; smelt never calls this for you.\n */\nimport type { RerankCandidate, RerankedCandidate, RerankStage } from /* your install */ '@smeltjs/core';\n\n/** The env var YOUR code reads. Rename it to match your vendor. */\nconst API_KEY_ENV = 'RERANKER_API_KEY';\n\nexport const rerank: RerankStage = {\n id: 'my-reranker/v1',\n async rerank(\n candidates: readonly RerankCandidate[],\n query: string,\n ): Promise<readonly RerankedCandidate[]> {\n const apiKey = process.env[API_KEY_ENV];\n if (apiKey === undefined || apiKey === '') {\n throw new Error(\n 'smelt.rerank.ts: ' + API_KEY_ENV + ' is not set. This stage makes an outbound ' +\n 'HTTP call from YOUR code with YOUR key; without a key it refuses to pretend.',\n );\n }\n\n // TODO: the outbound call. Voyage AI is one example vendor; any reranker with an\n // HTTP API fits this shape:\n //\n // const response = await fetch('https://api.voyageai.com/v1/rerank', {\n // method: 'POST',\n // headers: {\n // 'content-type': 'application/json',\n // authorization: \\`Bearer \\${apiKey}\\`,\n // },\n // body: JSON.stringify({\n // query,\n // documents: candidates.map((candidate) => candidate.text),\n // }),\n // });\n // if (!response.ok) throw new Error('rerank failed: ' + String(response.status));\n // const body = (await response.json()) as {\n // data: { index: number; relevance_score: number }[];\n // };\n // return body.data.map(({ index, relevance_score }) => ({\n // ...candidates[index]!,\n // score: relevance_score,\n // }));\n //\n void candidates;\n void query;\n throw new Error(\n 'smelt.rerank.ts: implement the outbound call sketched above, then delete this throw.',\n );\n },\n};\n`;\n}\n"]}
package/dist/cli/init.js CHANGED
@@ -319,8 +319,10 @@ const STRATEGY_BLURB = {
319
319
  structural: `parses ${String(STRUCTURAL_LANGUAGES.length)} languages with bundled grammars ` +
320
320
  `(${STRUCTURAL_LANGUAGES.join(', ')}) and collapses siblings by name; refuses ` +
321
321
  `other languages rather than approximating`,
322
- auto: 'structural for those languages and lexical for everything else; the result ' +
323
- 'names whichever one actually ran',
322
+ auto: 'content kind first (json, diff), then structural for those languages and ' +
323
+ 'lexical for everything else; the result names whichever one actually ran',
324
+ json: 'members and elements as units, for JSON tool results; refuses non-JSON',
325
+ diff: 'files and hunks as units, for unified diffs; refuses anything else',
324
326
  };
325
327
  async function stepStrategy(io, ask, choices) {
326
328
  const picks = STRATEGIES.map((_, index) => String(index + 1));