@smeltjs/core 0.4.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.
- package/dist/apply.d.ts.map +1 -1
- package/dist/apply.js +8 -2
- package/dist/apply.js.map +1 -1
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js +9 -4
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/doctor.d.ts +66 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/hooks.d.ts +36 -2
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +108 -64
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +12 -16
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/installed.d.ts +56 -0
- package/dist/cli/installed.d.ts.map +1 -0
- package/dist/cli/installed.js +105 -0
- package/dist/cli/installed.js.map +1 -0
- package/dist/cli/lava.d.ts +31 -0
- package/dist/cli/lava.d.ts.map +1 -0
- package/dist/cli/lava.js +82 -0
- package/dist/cli/lava.js.map +1 -0
- package/dist/cli/report.d.ts +10 -1
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +16 -1
- package/dist/cli/report.js.map +1 -1
- package/dist/cli/setup.d.ts +87 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +513 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/shell.d.ts +11 -4
- package/dist/cli/shell.d.ts.map +1 -1
- package/dist/cli/shell.js.map +1 -1
- package/dist/cli/subcommands/agents.d.ts.map +1 -1
- package/dist/cli/subcommands/agents.js +2 -1
- package/dist/cli/subcommands/agents.js.map +1 -1
- package/dist/cli/subcommands/doctor.d.ts +15 -0
- package/dist/cli/subcommands/doctor.d.ts.map +1 -0
- package/dist/cli/subcommands/doctor.js +43 -0
- package/dist/cli/subcommands/doctor.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +16 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -1
- package/dist/cli/subcommands/flags.js +43 -4
- package/dist/cli/subcommands/flags.js.map +1 -1
- package/dist/cli/subcommands/hooks.d.ts.map +1 -1
- package/dist/cli/subcommands/hooks.js +10 -2
- package/dist/cli/subcommands/hooks.js.map +1 -1
- package/dist/cli/subcommands/init.d.ts.map +1 -1
- package/dist/cli/subcommands/init.js +2 -1
- package/dist/cli/subcommands/init.js.map +1 -1
- package/dist/cli/subcommands/registry.d.ts +3 -1
- package/dist/cli/subcommands/registry.d.ts.map +1 -1
- package/dist/cli/subcommands/registry.js +6 -0
- package/dist/cli/subcommands/registry.js.map +1 -1
- package/dist/cli/subcommands/setup.d.ts +23 -0
- package/dist/cli/subcommands/setup.d.ts.map +1 -0
- package/dist/cli/subcommands/setup.js +87 -0
- package/dist/cli/subcommands/setup.js.map +1 -0
- package/dist/cli/subcommands/smelt.d.ts +3 -0
- package/dist/cli/subcommands/smelt.d.ts.map +1 -1
- package/dist/cli/subcommands/smelt.js +5 -1
- package/dist/cli/subcommands/smelt.js.map +1 -1
- package/dist/cli/subcommands/stats.d.ts +8 -3
- package/dist/cli/subcommands/stats.d.ts.map +1 -1
- package/dist/cli/subcommands/stats.js +15 -5
- package/dist/cli/subcommands/stats.js.map +1 -1
- package/dist/cli/subcommands/subcommand.d.ts +1 -1
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
- package/dist/cli/subcommands/subcommand.js.map +1 -1
- package/dist/cli/wizard.d.ts +72 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +86 -0
- package/dist/cli/wizard.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/harness/claude-code.d.ts.map +1 -1
- package/dist/harness/claude-code.js +10 -0
- package/dist/harness/claude-code.js.map +1 -1
- package/dist/harness/codex.d.ts.map +1 -1
- package/dist/harness/codex.js +1 -0
- package/dist/harness/codex.js.map +1 -1
- package/dist/harness/grok.d.ts.map +1 -1
- package/dist/harness/grok.js +4 -1
- package/dist/harness/grok.js.map +1 -1
- package/dist/harness/kilocode.d.ts.map +1 -1
- package/dist/harness/kilocode.js +1 -1
- package/dist/harness/kilocode.js.map +1 -1
- package/dist/harness/opencode.d.ts.map +1 -1
- package/dist/harness/opencode.js +10 -0
- package/dist/harness/opencode.js.map +1 -1
- package/dist/harness/profile.d.ts +23 -2
- package/dist/harness/profile.d.ts.map +1 -1
- package/dist/harness/profile.js.map +1 -1
- package/dist/harness/snippet.d.ts +14 -1
- package/dist/harness/snippet.d.ts.map +1 -1
- package/dist/harness/snippet.js +18 -2
- package/dist/harness/snippet.js.map +1 -1
- package/dist/hooks/focus-terms.d.ts +57 -0
- package/dist/hooks/focus-terms.d.ts.map +1 -0
- package/dist/hooks/focus-terms.js +230 -0
- package/dist/hooks/focus-terms.js.map +1 -0
- package/dist/hooks/guard-core.d.ts +8 -16
- package/dist/hooks/guard-core.d.ts.map +1 -1
- package/dist/hooks/guard-core.js +22 -120
- package/dist/hooks/guard-core.js.map +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/ops/index.d.ts +3 -3
- package/dist/ops/index.d.ts.map +1 -1
- package/dist/ops/index.js +2 -2
- package/dist/ops/index.js.map +1 -1
- package/dist/ops/verbs.d.ts +57 -1
- package/dist/ops/verbs.d.ts.map +1 -1
- package/dist/ops/verbs.js +49 -1
- package/dist/ops/verbs.js.map +1 -1
- package/dist/plan/auto.d.ts +9 -2
- package/dist/plan/auto.d.ts.map +1 -1
- package/dist/plan/auto.js +14 -2
- package/dist/plan/auto.js.map +1 -1
- package/dist/plan/diff.d.ts +45 -0
- package/dist/plan/diff.d.ts.map +1 -0
- package/dist/plan/diff.js +284 -0
- package/dist/plan/diff.js.map +1 -0
- package/dist/plan/json.d.ts +37 -0
- package/dist/plan/json.d.ts.map +1 -0
- package/dist/plan/json.js +181 -0
- package/dist/plan/json.js.map +1 -0
- package/dist/plan/kind.d.ts +28 -0
- package/dist/plan/kind.d.ts.map +1 -0
- package/dist/plan/kind.js +57 -0
- package/dist/plan/kind.js.map +1 -0
- package/dist/plan/offsets.d.ts +8 -0
- package/dist/plan/offsets.d.ts.map +1 -0
- package/dist/plan/offsets.js +19 -0
- package/dist/plan/offsets.js.map +1 -0
- package/dist/plan/planners.d.ts +15 -5
- package/dist/plan/planners.d.ts.map +1 -1
- package/dist/plan/planners.js +13 -5
- package/dist/plan/planners.js.map +1 -1
- package/dist/plan/structural.d.ts.map +1 -1
- package/dist/plan/structural.js +106 -14
- package/dist/plan/structural.js.map +1 -1
- package/dist/retrieve.d.ts +22 -1
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +59 -0
- package/dist/retrieve.js.map +1 -1
- package/dist/setup/recipe.d.ts +73 -0
- package/dist/setup/recipe.d.ts.map +1 -0
- package/dist/setup/recipe.js +76 -0
- package/dist/setup/recipe.js.map +1 -0
- package/dist/smelter.d.ts +4 -0
- package/dist/smelter.d.ts.map +1 -1
- package/dist/smelter.js +4 -0
- package/dist/smelter.js.map +1 -1
- package/dist/stats.d.ts +16 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +29 -0
- package/dist/stats.js.map +1 -1
- package/dist/store-dir.d.ts +9 -2
- package/dist/store-dir.d.ts.map +1 -1
- package/dist/store-dir.js +48 -7
- package/dist/store-dir.js.map +1 -1
- package/dist/store.d.ts +4 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +12 -2
- package/dist/store.js.map +1 -1
- package/dist/text/json-edit.d.ts +15 -1
- package/dist/text/json-edit.d.ts.map +1 -1
- package/dist/text/json-edit.js +76 -5
- package/dist/text/json-edit.js.map +1 -1
- package/dist/types.d.ts +116 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/apply.d.ts.map
CHANGED
|
@@ -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
|
-
|
|
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":"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,IAAI,OAAO,EAAE,CAAC;QACxC,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,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACpC,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;SACP,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 } 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"]}
|
package/dist/cli/bin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // `
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // The lava renderer's switch: a real interactive terminal that has not been\n // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean\n // exactly the bytes the wizards have always written.\n color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,\n // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by\n // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A\n // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking\n // mode and breaks readStdin's readFileSync(0) for every other mode — only a\n // wizard branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
|
package/dist/cli/bin.js
CHANGED
|
@@ -144,10 +144,15 @@ try {
|
|
|
144
144
|
stdin: readStdin,
|
|
145
145
|
version: packageVersion(),
|
|
146
146
|
cwd: process.cwd(),
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
147
|
+
// The lava renderer's switch: a real interactive terminal that has not been
|
|
148
|
+
// told to keep its bytes plain. Piped output, agents and NO_COLOR all mean
|
|
149
|
+
// exactly the bytes the wizards have always written.
|
|
150
|
+
color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,
|
|
151
|
+
// The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by
|
|
152
|
+
// line, so they get the stream, not readStdin's one-shot slurp of fd 0. A
|
|
153
|
+
// *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking
|
|
154
|
+
// mode and breaks readStdin's readFileSync(0) for every other mode — only a
|
|
155
|
+
// wizard branch of runCli ever reads this property.
|
|
151
156
|
get initInput() {
|
|
152
157
|
return process.stdin;
|
|
153
158
|
},
|
package/dist/cli/bin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // `smelt init` reads answers line by line, so it gets the stream, not readStdin's\n // one-shot slurp of fd 0. A *getter*, because touching `process.stdin` at all\n // flips fd 0 into non-blocking mode and breaks readStdin's readFileSync(0) for\n // every other mode — only the `init` branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // The lava renderer's switch: a real interactive terminal that has not been\n // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean\n // exactly the bytes the wizards have always written.\n color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,\n // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by\n // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A\n // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking\n // mode and breaks readStdin's readFileSync(0) for every other mode — only a\n // wizard branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `smelt doctor` — the verdicts over InstalledState. The reading lives in
|
|
3
|
+
* `cli/installed.ts` (one reader, behind the three consumers); this module decides
|
|
4
|
+
* and reports: which blocks are behind the running binary, which pieces are orphans,
|
|
5
|
+
* what the repair is, and what the exit code means. Everything the writers wrote,
|
|
6
|
+
* this verb reads back and compares — and never writes a byte of (ADR-0003).
|
|
7
|
+
*
|
|
8
|
+
* When something is behind, the report ends with the exact repair command —
|
|
9
|
+
* `smelt setup`, per harness where a block is behind — and nothing more happens.
|
|
10
|
+
* The exit code carries the verdict, so the other-machine loop — upgrade, doctor,
|
|
11
|
+
* setup — needs no prose parsing.
|
|
12
|
+
*/
|
|
13
|
+
export interface DoctorIo {
|
|
14
|
+
readonly output: (text: string) => void;
|
|
15
|
+
/** Where installed state is read: config discovery, instruction files, hook files. */
|
|
16
|
+
readonly cwd: string;
|
|
17
|
+
/** The running binary's version — what "current" is measured against. */
|
|
18
|
+
readonly version: string;
|
|
19
|
+
}
|
|
20
|
+
export interface DoctorOptions {
|
|
21
|
+
readonly json: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** One instruction block found on disk, with the release that wrote it. */
|
|
24
|
+
export interface DoctorBlock {
|
|
25
|
+
readonly file: string;
|
|
26
|
+
/** The harness profiles whose instruction file this is (often exactly one). */
|
|
27
|
+
readonly harnesses: readonly string[];
|
|
28
|
+
/** The release that wrote it, or `undefined` when it predates stamping. */
|
|
29
|
+
readonly installedBy?: string;
|
|
30
|
+
readonly status: 'current' | 'behind' | 'unversioned';
|
|
31
|
+
}
|
|
32
|
+
/** The config as doctor saw it. A malformed config is a finding, not a crash. */
|
|
33
|
+
export interface DoctorConfig {
|
|
34
|
+
readonly present: boolean;
|
|
35
|
+
readonly malformed?: boolean;
|
|
36
|
+
readonly schemaVersion?: number;
|
|
37
|
+
readonly currentSchema?: boolean;
|
|
38
|
+
readonly budgetBytes?: number;
|
|
39
|
+
readonly store: {
|
|
40
|
+
readonly kind?: 'directory' | 'memory';
|
|
41
|
+
readonly path?: string;
|
|
42
|
+
readonly dirExists?: boolean;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** One MCP registration found (or notably absent) on disk. */
|
|
46
|
+
export interface DoctorMcp {
|
|
47
|
+
readonly file: string;
|
|
48
|
+
readonly server: string;
|
|
49
|
+
readonly registered: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** The machine receipt — `--json`. Everything doctor read, and the verdict. */
|
|
52
|
+
export interface DoctorReceipt {
|
|
53
|
+
readonly format: 'smelt.doctor.v1';
|
|
54
|
+
readonly version: string;
|
|
55
|
+
/** True when something is installed and nothing is behind and there are no orphans. */
|
|
56
|
+
readonly current: boolean;
|
|
57
|
+
readonly installed: boolean;
|
|
58
|
+
readonly config: DoctorConfig;
|
|
59
|
+
readonly blocks: readonly DoctorBlock[];
|
|
60
|
+
readonly hookFiles: readonly string[];
|
|
61
|
+
readonly mcp: readonly DoctorMcp[];
|
|
62
|
+
readonly orphans: readonly string[];
|
|
63
|
+
readonly repair: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
export declare function runDoctor(options: DoctorOptions, io: DoctorIo): number;
|
|
66
|
+
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/cli/doctor.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,sFAAsF;IACtF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,aAAa,CAAC;CACvD;AAED,iFAAiF;AACjF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,QAAQ,CAAC;QACvC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;KAC9B,CAAC;CACH;AAED,8DAA8D;AAC9D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AAED,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uFAAuF;IACvF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;IACxC,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,GAAG,EAAE,SAAS,SAAS,EAAE,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,EAAE,EAAE,EAAE,QAAQ,GAAG,MAAM,CA2ItE","sourcesContent":["import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\nimport { CONFIG_FILE_NAME, CONFIG_VERSION } from './config.ts';\nimport { readInstalledState } from './installed.ts';\nimport type { InstalledBlock } from './installed.ts';\nimport { CLI_NAME, EXIT } from './shell.ts';\n\n/**\n * `smelt doctor` — the verdicts over InstalledState. The reading lives in\n * `cli/installed.ts` (one reader, behind the three consumers); this module decides\n * and reports: which blocks are behind the running binary, which pieces are orphans,\n * what the repair is, and what the exit code means. Everything the writers wrote,\n * this verb reads back and compares — and never writes a byte of (ADR-0003).\n *\n * When something is behind, the report ends with the exact repair command —\n * `smelt setup`, per harness where a block is behind — and nothing more happens.\n * The exit code carries the verdict, so the other-machine loop — upgrade, doctor,\n * setup — needs no prose parsing.\n */\n\nexport interface DoctorIo {\n readonly output: (text: string) => void;\n /** Where installed state is read: config discovery, instruction files, hook files. */\n readonly cwd: string;\n /** The running binary's version — what \"current\" is measured against. */\n readonly version: string;\n}\n\nexport interface DoctorOptions {\n readonly json: boolean;\n}\n\n/** One instruction block found on disk, with the release that wrote it. */\nexport interface DoctorBlock {\n readonly file: string;\n /** The harness profiles whose instruction file this is (often exactly one). */\n readonly harnesses: readonly string[];\n /** The release that wrote it, or `undefined` when it predates stamping. */\n readonly installedBy?: string;\n readonly status: 'current' | 'behind' | 'unversioned';\n}\n\n/** The config as doctor saw it. A malformed config is a finding, not a crash. */\nexport interface DoctorConfig {\n readonly present: boolean;\n readonly malformed?: boolean;\n readonly schemaVersion?: number;\n readonly currentSchema?: boolean;\n readonly budgetBytes?: number;\n readonly store: {\n readonly kind?: 'directory' | 'memory';\n readonly path?: string;\n readonly dirExists?: boolean;\n };\n}\n\n/** One MCP registration found (or notably absent) on disk. */\nexport interface DoctorMcp {\n readonly file: string;\n readonly server: string;\n readonly registered: boolean;\n}\n\n/** The machine receipt — `--json`. Everything doctor read, and the verdict. */\nexport interface DoctorReceipt {\n readonly format: 'smelt.doctor.v1';\n readonly version: string;\n /** True when something is installed and nothing is behind and there are no orphans. */\n readonly current: boolean;\n readonly installed: boolean;\n readonly config: DoctorConfig;\n readonly blocks: readonly DoctorBlock[];\n readonly hookFiles: readonly string[];\n readonly mcp: readonly DoctorMcp[];\n readonly orphans: readonly string[];\n readonly repair: readonly string[];\n}\n\nexport function runDoctor(options: DoctorOptions, io: DoctorIo): number {\n const say = (text: string): void => {\n if (!options.json) io.output(text);\n };\n const orphans: string[] = [];\n const repair: string[] = [];\n\n const state = readInstalledState(io.cwd);\n\n // ── verdict: blocks vs the running binary ──\n const blocks: DoctorBlock[] = state.blocks.map((block) => ({\n file: block.file,\n harnesses: block.harnesses,\n ...(block.installedBy === undefined ? {} : { installedBy: block.installedBy }),\n status: blockStatus(block, io.version),\n }));\n const behindBlocks = blocks.filter((block) => block.status === 'behind');\n for (const block of behindBlocks) {\n repair.push(...block.harnesses.map((id) => `${CLI_NAME} setup --harness ${id}`));\n }\n\n // ── config detail + the store-directory orphan ──\n let config: DoctorConfig = { present: false, store: {} };\n if (state.config.present) {\n if (state.config.malformed === true || state.config.parsed === undefined) {\n config = { present: true, malformed: true, store: {} };\n orphans.push(\n `${CONFIG_FILE_NAME} is malformed: ${state.config.malformedWhy ?? 'unparseable JSON'}`,\n );\n repair.push(`${CLI_NAME} setup`);\n } else {\n const parsed = state.config.parsed;\n const configPath = state.config.path ?? join(io.cwd, CONFIG_FILE_NAME);\n const dirExists =\n parsed.store?.kind === 'directory'\n ? existsSync(join(dirname(configPath), parsed.store.path))\n : undefined;\n config = {\n present: true,\n schemaVersion: parsed.smeltConfig,\n currentSchema: parsed.smeltConfig === CONFIG_VERSION,\n ...(parsed.defaultBudgetBytes === undefined\n ? {}\n : { budgetBytes: parsed.defaultBudgetBytes }),\n store: {\n ...(parsed.store === undefined ? {} : { kind: parsed.store.kind }),\n ...(parsed.store?.kind === 'directory' ? { path: parsed.store.path } : {}),\n ...(dirExists === undefined ? {} : { dirExists }),\n },\n };\n if (parsed.store?.kind === 'directory' && dirExists === false) {\n orphans.push(\n `the store directory (${parsed.store.path}) does not exist — retrieves across processes would fail`,\n );\n repair.push(`${CLI_NAME} setup`);\n }\n }\n }\n\n // ── orphans: pieces whose partners are missing ──\n const wired = state.blocks.length > 0 || state.hookFiles.length > 0;\n if (state.mcp.some((one) => one.registered) && !wired) {\n orphans.push(\n 'an MCP registration is present but no hooks wiring is — the guard and the retrieval contract travel together',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n if (wired && !state.config.present) {\n orphans.push(\n 'hooks are wired but there is no smelt.config.json — the store and budget the hooks promise live there',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n\n // ── verdict ──\n const installed = wired || state.config.present || state.mcp.some((one) => one.registered);\n const current = installed && behindBlocks.length === 0 && orphans.length === 0;\n\n say(`${CLI_NAME} doctor — binary ${io.version}, reading ${io.cwd}\\n`);\n if (!installed) {\n say(`Nothing of smelt's is installed here. \\`${CLI_NAME} setup\\` would change that.\\n`);\n } else {\n if (config.present) {\n say(\n ` ${CONFIG_FILE_NAME}: ${\n config.malformed === true\n ? 'MALFORMED'\n : `schema ${String(config.schemaVersion)}, budget ${\n config.budgetBytes === undefined ? 'unset' : String(config.budgetBytes)\n }, store ${describeStore(config)}`\n }\\n`,\n );\n } else {\n say(` ${CONFIG_FILE_NAME}: absent\\n`);\n }\n for (const block of blocks) {\n say(\n ` ${block.file}: written by ${\n block.installedBy ?? 'a pre-stamping release (unversioned)'\n } [${block.status}] — ${block.harnesses.join(', ')}\\n`,\n );\n }\n for (const name of state.hookFiles) say(` ${name}: wired\\n`);\n for (const one of state.mcp) {\n if (one.registered) say(` ${one.file}: ${one.server} registered\\n`);\n }\n for (const orphan of orphans) say(` ORPHAN: ${orphan}\\n`);\n if (behindBlocks.length > 0) {\n say(\n `\\nBehind: the running binary is ${io.version}; re-run setup to bring the ` +\n `installed state to it:\\n` +\n [...new Set(repair)].map((command) => ` ${command}\\n`).join(''),\n );\n } else if (orphans.length > 0) {\n say(`\\nRepair:\\n${[...new Set(repair)].map((command) => ` ${command}\\n`).join('')}`);\n }\n say(\n current\n ? `Current: everything on disk agrees with binary ${io.version}.\\n`\n : `Not current — see above. Doctor never writes; ${CLI_NAME} setup is the repair.\\n`,\n );\n }\n\n if (options.json) {\n const receipt: DoctorReceipt = {\n format: 'smelt.doctor.v1',\n version: io.version,\n current,\n installed,\n config,\n blocks,\n hookFiles: [...state.hookFiles],\n mcp: [...state.mcp],\n orphans,\n repair: [...new Set(repair)],\n };\n io.output(JSON.stringify(receipt, null, 2) + '\\n');\n }\n return current || !installed ? EXIT.ok : EXIT.refused;\n}\n\n/** The verdict over one block: whole-owned files carry no stamp to compare. */\nfunction blockStatus(block: InstalledBlock, binaryVersion: string): DoctorBlock['status'] {\n if (!block.stampable) return 'unversioned';\n return block.installedBy === binaryVersion ? 'current' : 'behind';\n}\n\nfunction describeStore(config: DoctorConfig): string {\n if (config.store.kind === undefined) return 'unset';\n if (config.store.kind === 'memory') return 'memory';\n return `directory at ${config.store.path ?? ''} (${\n config.store.dirExists ? 'present' : 'MISSING'\n })`;\n}\n"]}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { CONFIG_FILE_NAME, CONFIG_VERSION } from './config.js';
|
|
4
|
+
import { readInstalledState } from './installed.js';
|
|
5
|
+
import { CLI_NAME, EXIT } from './shell.js';
|
|
6
|
+
export function runDoctor(options, io) {
|
|
7
|
+
const say = (text) => {
|
|
8
|
+
if (!options.json)
|
|
9
|
+
io.output(text);
|
|
10
|
+
};
|
|
11
|
+
const orphans = [];
|
|
12
|
+
const repair = [];
|
|
13
|
+
const state = readInstalledState(io.cwd);
|
|
14
|
+
// ── verdict: blocks vs the running binary ──
|
|
15
|
+
const blocks = state.blocks.map((block) => ({
|
|
16
|
+
file: block.file,
|
|
17
|
+
harnesses: block.harnesses,
|
|
18
|
+
...(block.installedBy === undefined ? {} : { installedBy: block.installedBy }),
|
|
19
|
+
status: blockStatus(block, io.version),
|
|
20
|
+
}));
|
|
21
|
+
const behindBlocks = blocks.filter((block) => block.status === 'behind');
|
|
22
|
+
for (const block of behindBlocks) {
|
|
23
|
+
repair.push(...block.harnesses.map((id) => `${CLI_NAME} setup --harness ${id}`));
|
|
24
|
+
}
|
|
25
|
+
// ── config detail + the store-directory orphan ──
|
|
26
|
+
let config = { present: false, store: {} };
|
|
27
|
+
if (state.config.present) {
|
|
28
|
+
if (state.config.malformed === true || state.config.parsed === undefined) {
|
|
29
|
+
config = { present: true, malformed: true, store: {} };
|
|
30
|
+
orphans.push(`${CONFIG_FILE_NAME} is malformed: ${state.config.malformedWhy ?? 'unparseable JSON'}`);
|
|
31
|
+
repair.push(`${CLI_NAME} setup`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const parsed = state.config.parsed;
|
|
35
|
+
const configPath = state.config.path ?? join(io.cwd, CONFIG_FILE_NAME);
|
|
36
|
+
const dirExists = parsed.store?.kind === 'directory'
|
|
37
|
+
? existsSync(join(dirname(configPath), parsed.store.path))
|
|
38
|
+
: undefined;
|
|
39
|
+
config = {
|
|
40
|
+
present: true,
|
|
41
|
+
schemaVersion: parsed.smeltConfig,
|
|
42
|
+
currentSchema: parsed.smeltConfig === CONFIG_VERSION,
|
|
43
|
+
...(parsed.defaultBudgetBytes === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { budgetBytes: parsed.defaultBudgetBytes }),
|
|
46
|
+
store: {
|
|
47
|
+
...(parsed.store === undefined ? {} : { kind: parsed.store.kind }),
|
|
48
|
+
...(parsed.store?.kind === 'directory' ? { path: parsed.store.path } : {}),
|
|
49
|
+
...(dirExists === undefined ? {} : { dirExists }),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
if (parsed.store?.kind === 'directory' && dirExists === false) {
|
|
53
|
+
orphans.push(`the store directory (${parsed.store.path}) does not exist — retrieves across processes would fail`);
|
|
54
|
+
repair.push(`${CLI_NAME} setup`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── orphans: pieces whose partners are missing ──
|
|
59
|
+
const wired = state.blocks.length > 0 || state.hookFiles.length > 0;
|
|
60
|
+
if (state.mcp.some((one) => one.registered) && !wired) {
|
|
61
|
+
orphans.push('an MCP registration is present but no hooks wiring is — the guard and the retrieval contract travel together');
|
|
62
|
+
repair.push(`${CLI_NAME} setup`);
|
|
63
|
+
}
|
|
64
|
+
if (wired && !state.config.present) {
|
|
65
|
+
orphans.push('hooks are wired but there is no smelt.config.json — the store and budget the hooks promise live there');
|
|
66
|
+
repair.push(`${CLI_NAME} setup`);
|
|
67
|
+
}
|
|
68
|
+
// ── verdict ──
|
|
69
|
+
const installed = wired || state.config.present || state.mcp.some((one) => one.registered);
|
|
70
|
+
const current = installed && behindBlocks.length === 0 && orphans.length === 0;
|
|
71
|
+
say(`${CLI_NAME} doctor — binary ${io.version}, reading ${io.cwd}\n`);
|
|
72
|
+
if (!installed) {
|
|
73
|
+
say(`Nothing of smelt's is installed here. \`${CLI_NAME} setup\` would change that.\n`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
if (config.present) {
|
|
77
|
+
say(` ${CONFIG_FILE_NAME}: ${config.malformed === true
|
|
78
|
+
? 'MALFORMED'
|
|
79
|
+
: `schema ${String(config.schemaVersion)}, budget ${config.budgetBytes === undefined ? 'unset' : String(config.budgetBytes)}, store ${describeStore(config)}`}\n`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
say(` ${CONFIG_FILE_NAME}: absent\n`);
|
|
83
|
+
}
|
|
84
|
+
for (const block of blocks) {
|
|
85
|
+
say(` ${block.file}: written by ${block.installedBy ?? 'a pre-stamping release (unversioned)'} [${block.status}] — ${block.harnesses.join(', ')}\n`);
|
|
86
|
+
}
|
|
87
|
+
for (const name of state.hookFiles)
|
|
88
|
+
say(` ${name}: wired\n`);
|
|
89
|
+
for (const one of state.mcp) {
|
|
90
|
+
if (one.registered)
|
|
91
|
+
say(` ${one.file}: ${one.server} registered\n`);
|
|
92
|
+
}
|
|
93
|
+
for (const orphan of orphans)
|
|
94
|
+
say(` ORPHAN: ${orphan}\n`);
|
|
95
|
+
if (behindBlocks.length > 0) {
|
|
96
|
+
say(`\nBehind: the running binary is ${io.version}; re-run setup to bring the ` +
|
|
97
|
+
`installed state to it:\n` +
|
|
98
|
+
[...new Set(repair)].map((command) => ` ${command}\n`).join(''));
|
|
99
|
+
}
|
|
100
|
+
else if (orphans.length > 0) {
|
|
101
|
+
say(`\nRepair:\n${[...new Set(repair)].map((command) => ` ${command}\n`).join('')}`);
|
|
102
|
+
}
|
|
103
|
+
say(current
|
|
104
|
+
? `Current: everything on disk agrees with binary ${io.version}.\n`
|
|
105
|
+
: `Not current — see above. Doctor never writes; ${CLI_NAME} setup is the repair.\n`);
|
|
106
|
+
}
|
|
107
|
+
if (options.json) {
|
|
108
|
+
const receipt = {
|
|
109
|
+
format: 'smelt.doctor.v1',
|
|
110
|
+
version: io.version,
|
|
111
|
+
current,
|
|
112
|
+
installed,
|
|
113
|
+
config,
|
|
114
|
+
blocks,
|
|
115
|
+
hookFiles: [...state.hookFiles],
|
|
116
|
+
mcp: [...state.mcp],
|
|
117
|
+
orphans,
|
|
118
|
+
repair: [...new Set(repair)],
|
|
119
|
+
};
|
|
120
|
+
io.output(JSON.stringify(receipt, null, 2) + '\n');
|
|
121
|
+
}
|
|
122
|
+
return current || !installed ? EXIT.ok : EXIT.refused;
|
|
123
|
+
}
|
|
124
|
+
/** The verdict over one block: whole-owned files carry no stamp to compare. */
|
|
125
|
+
function blockStatus(block, binaryVersion) {
|
|
126
|
+
if (!block.stampable)
|
|
127
|
+
return 'unversioned';
|
|
128
|
+
return block.installedBy === binaryVersion ? 'current' : 'behind';
|
|
129
|
+
}
|
|
130
|
+
function describeStore(config) {
|
|
131
|
+
if (config.store.kind === undefined)
|
|
132
|
+
return 'unset';
|
|
133
|
+
if (config.store.kind === 'memory')
|
|
134
|
+
return 'memory';
|
|
135
|
+
return `directory at ${config.store.path ?? ''} (${config.store.dirExists ? 'present' : 'MISSING'})`;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.js","sourceRoot":"","sources":["../../src/cli/doctor.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\nimport { CONFIG_FILE_NAME, CONFIG_VERSION } from './config.ts';\nimport { readInstalledState } from './installed.ts';\nimport type { InstalledBlock } from './installed.ts';\nimport { CLI_NAME, EXIT } from './shell.ts';\n\n/**\n * `smelt doctor` — the verdicts over InstalledState. The reading lives in\n * `cli/installed.ts` (one reader, behind the three consumers); this module decides\n * and reports: which blocks are behind the running binary, which pieces are orphans,\n * what the repair is, and what the exit code means. Everything the writers wrote,\n * this verb reads back and compares — and never writes a byte of (ADR-0003).\n *\n * When something is behind, the report ends with the exact repair command —\n * `smelt setup`, per harness where a block is behind — and nothing more happens.\n * The exit code carries the verdict, so the other-machine loop — upgrade, doctor,\n * setup — needs no prose parsing.\n */\n\nexport interface DoctorIo {\n readonly output: (text: string) => void;\n /** Where installed state is read: config discovery, instruction files, hook files. */\n readonly cwd: string;\n /** The running binary's version — what \"current\" is measured against. */\n readonly version: string;\n}\n\nexport interface DoctorOptions {\n readonly json: boolean;\n}\n\n/** One instruction block found on disk, with the release that wrote it. */\nexport interface DoctorBlock {\n readonly file: string;\n /** The harness profiles whose instruction file this is (often exactly one). */\n readonly harnesses: readonly string[];\n /** The release that wrote it, or `undefined` when it predates stamping. */\n readonly installedBy?: string;\n readonly status: 'current' | 'behind' | 'unversioned';\n}\n\n/** The config as doctor saw it. A malformed config is a finding, not a crash. */\nexport interface DoctorConfig {\n readonly present: boolean;\n readonly malformed?: boolean;\n readonly schemaVersion?: number;\n readonly currentSchema?: boolean;\n readonly budgetBytes?: number;\n readonly store: {\n readonly kind?: 'directory' | 'memory';\n readonly path?: string;\n readonly dirExists?: boolean;\n };\n}\n\n/** One MCP registration found (or notably absent) on disk. */\nexport interface DoctorMcp {\n readonly file: string;\n readonly server: string;\n readonly registered: boolean;\n}\n\n/** The machine receipt — `--json`. Everything doctor read, and the verdict. */\nexport interface DoctorReceipt {\n readonly format: 'smelt.doctor.v1';\n readonly version: string;\n /** True when something is installed and nothing is behind and there are no orphans. */\n readonly current: boolean;\n readonly installed: boolean;\n readonly config: DoctorConfig;\n readonly blocks: readonly DoctorBlock[];\n readonly hookFiles: readonly string[];\n readonly mcp: readonly DoctorMcp[];\n readonly orphans: readonly string[];\n readonly repair: readonly string[];\n}\n\nexport function runDoctor(options: DoctorOptions, io: DoctorIo): number {\n const say = (text: string): void => {\n if (!options.json) io.output(text);\n };\n const orphans: string[] = [];\n const repair: string[] = [];\n\n const state = readInstalledState(io.cwd);\n\n // ── verdict: blocks vs the running binary ──\n const blocks: DoctorBlock[] = state.blocks.map((block) => ({\n file: block.file,\n harnesses: block.harnesses,\n ...(block.installedBy === undefined ? {} : { installedBy: block.installedBy }),\n status: blockStatus(block, io.version),\n }));\n const behindBlocks = blocks.filter((block) => block.status === 'behind');\n for (const block of behindBlocks) {\n repair.push(...block.harnesses.map((id) => `${CLI_NAME} setup --harness ${id}`));\n }\n\n // ── config detail + the store-directory orphan ──\n let config: DoctorConfig = { present: false, store: {} };\n if (state.config.present) {\n if (state.config.malformed === true || state.config.parsed === undefined) {\n config = { present: true, malformed: true, store: {} };\n orphans.push(\n `${CONFIG_FILE_NAME} is malformed: ${state.config.malformedWhy ?? 'unparseable JSON'}`,\n );\n repair.push(`${CLI_NAME} setup`);\n } else {\n const parsed = state.config.parsed;\n const configPath = state.config.path ?? join(io.cwd, CONFIG_FILE_NAME);\n const dirExists =\n parsed.store?.kind === 'directory'\n ? existsSync(join(dirname(configPath), parsed.store.path))\n : undefined;\n config = {\n present: true,\n schemaVersion: parsed.smeltConfig,\n currentSchema: parsed.smeltConfig === CONFIG_VERSION,\n ...(parsed.defaultBudgetBytes === undefined\n ? {}\n : { budgetBytes: parsed.defaultBudgetBytes }),\n store: {\n ...(parsed.store === undefined ? {} : { kind: parsed.store.kind }),\n ...(parsed.store?.kind === 'directory' ? { path: parsed.store.path } : {}),\n ...(dirExists === undefined ? {} : { dirExists }),\n },\n };\n if (parsed.store?.kind === 'directory' && dirExists === false) {\n orphans.push(\n `the store directory (${parsed.store.path}) does not exist — retrieves across processes would fail`,\n );\n repair.push(`${CLI_NAME} setup`);\n }\n }\n }\n\n // ── orphans: pieces whose partners are missing ──\n const wired = state.blocks.length > 0 || state.hookFiles.length > 0;\n if (state.mcp.some((one) => one.registered) && !wired) {\n orphans.push(\n 'an MCP registration is present but no hooks wiring is — the guard and the retrieval contract travel together',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n if (wired && !state.config.present) {\n orphans.push(\n 'hooks are wired but there is no smelt.config.json — the store and budget the hooks promise live there',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n\n // ── verdict ──\n const installed = wired || state.config.present || state.mcp.some((one) => one.registered);\n const current = installed && behindBlocks.length === 0 && orphans.length === 0;\n\n say(`${CLI_NAME} doctor — binary ${io.version}, reading ${io.cwd}\\n`);\n if (!installed) {\n say(`Nothing of smelt's is installed here. \\`${CLI_NAME} setup\\` would change that.\\n`);\n } else {\n if (config.present) {\n say(\n ` ${CONFIG_FILE_NAME}: ${\n config.malformed === true\n ? 'MALFORMED'\n : `schema ${String(config.schemaVersion)}, budget ${\n config.budgetBytes === undefined ? 'unset' : String(config.budgetBytes)\n }, store ${describeStore(config)}`\n }\\n`,\n );\n } else {\n say(` ${CONFIG_FILE_NAME}: absent\\n`);\n }\n for (const block of blocks) {\n say(\n ` ${block.file}: written by ${\n block.installedBy ?? 'a pre-stamping release (unversioned)'\n } [${block.status}] — ${block.harnesses.join(', ')}\\n`,\n );\n }\n for (const name of state.hookFiles) say(` ${name}: wired\\n`);\n for (const one of state.mcp) {\n if (one.registered) say(` ${one.file}: ${one.server} registered\\n`);\n }\n for (const orphan of orphans) say(` ORPHAN: ${orphan}\\n`);\n if (behindBlocks.length > 0) {\n say(\n `\\nBehind: the running binary is ${io.version}; re-run setup to bring the ` +\n `installed state to it:\\n` +\n [...new Set(repair)].map((command) => ` ${command}\\n`).join(''),\n );\n } else if (orphans.length > 0) {\n say(`\\nRepair:\\n${[...new Set(repair)].map((command) => ` ${command}\\n`).join('')}`);\n }\n say(\n current\n ? `Current: everything on disk agrees with binary ${io.version}.\\n`\n : `Not current — see above. Doctor never writes; ${CLI_NAME} setup is the repair.\\n`,\n );\n }\n\n if (options.json) {\n const receipt: DoctorReceipt = {\n format: 'smelt.doctor.v1',\n version: io.version,\n current,\n installed,\n config,\n blocks,\n hookFiles: [...state.hookFiles],\n mcp: [...state.mcp],\n orphans,\n repair: [...new Set(repair)],\n };\n io.output(JSON.stringify(receipt, null, 2) + '\\n');\n }\n return current || !installed ? EXIT.ok : EXIT.refused;\n}\n\n/** The verdict over one block: whole-owned files carry no stamp to compare. */\nfunction blockStatus(block: InstalledBlock, binaryVersion: string): DoctorBlock['status'] {\n if (!block.stampable) return 'unversioned';\n return block.installedBy === binaryVersion ? 'current' : 'behind';\n}\n\nfunction describeStore(config: DoctorConfig): string {\n if (config.store.kind === undefined) return 'unset';\n if (config.store.kind === 'memory') return 'memory';\n return `directory at ${config.store.path ?? ''} (${\n config.store.dirExists ? 'present' : 'MISSING'\n })`;\n}\n"]}
|