punctilio 1.2.10 → 1.4.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.
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+
4
+ import { countSeparators, assertSeparatorCountPreserved, formatErrorString } from "../utils.js"
5
+ import { DEFAULT_SEPARATOR } from "../constants.js"
6
+
7
+ describe("countSeparators", () => {
8
+ it.each([
9
+ ["empty string", "", DEFAULT_SEPARATOR, 0],
10
+ ["no separators", "hello world", DEFAULT_SEPARATOR, 0],
11
+ ["single separator", `a${DEFAULT_SEPARATOR}b`, DEFAULT_SEPARATOR, 1],
12
+ ["multiple separators", `${DEFAULT_SEPARATOR}a${DEFAULT_SEPARATOR}b${DEFAULT_SEPARATOR}`, DEFAULT_SEPARATOR, 3],
13
+ ["consecutive separators", `${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`, DEFAULT_SEPARATOR, 3],
14
+ ["custom separator", "a|b|c", "|", 2],
15
+ ])("%s: counts correctly", (_desc, input, separator, expected) => {
16
+ expect(countSeparators(input, separator)).toBe(expected)
17
+ })
18
+
19
+ it.each([
20
+ [`a${DEFAULT_SEPARATOR}b`, 1],
21
+ ["no separators", 0],
22
+ [`${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`, 2],
23
+ ])("uses DEFAULT_SEPARATOR when omitted: %s -> %i", (input, expected) => {
24
+ expect(countSeparators(input)).toBe(expected)
25
+ })
26
+ })
27
+
28
+ describe("assertSeparatorCountPreserved", () => {
29
+ describe("does not throw when count preserved", () => {
30
+ it.each([
31
+ ["same count", `a${DEFAULT_SEPARATOR}b`, `x${DEFAULT_SEPARATOR}y`],
32
+ ["zero count", "ab", "xy"],
33
+ ["multiple preserved", `${DEFAULT_SEPARATOR}a${DEFAULT_SEPARATOR}b${DEFAULT_SEPARATOR}`, `${DEFAULT_SEPARATOR}x${DEFAULT_SEPARATOR}y${DEFAULT_SEPARATOR}`],
34
+ ])("%s", (_desc, original, transformed) => {
35
+ expect(() => assertSeparatorCountPreserved(original, transformed, DEFAULT_SEPARATOR, "test")).not.toThrow()
36
+ })
37
+ })
38
+
39
+ describe("throws when count changes", () => {
40
+ it.each([
41
+ ["separator removed", `a${DEFAULT_SEPARATOR}b`, "ab", 1, 0],
42
+ ["separator added", "ab", `a${DEFAULT_SEPARATOR}b`, 0, 1],
43
+ ["multiple removed", `${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`, "", 2, 0],
44
+ ])("%s: expected %i, got %i", (_desc, original, transformed, expectedCount, actualCount) => {
45
+ expect(() => assertSeparatorCountPreserved(original, transformed, DEFAULT_SEPARATOR, "testFn"))
46
+ .toThrow(`testFn altered separator count: expected ${expectedCount}, got ${actualCount}`)
47
+ })
48
+ })
49
+
50
+ describe("default parameters", () => {
51
+ it.each([
52
+ ["throws when separator lost", `a${DEFAULT_SEPARATOR}b`, "ab", true],
53
+ ["does not throw when preserved", `a${DEFAULT_SEPARATOR}b`, `x${DEFAULT_SEPARATOR}y`, false],
54
+ ])("%s", (_desc, original, transformed, shouldThrow) => {
55
+ if (shouldThrow) {
56
+ expect(() => assertSeparatorCountPreserved(original, transformed)).toThrow()
57
+ } else {
58
+ expect(() => assertSeparatorCountPreserved(original, transformed)).not.toThrow()
59
+ }
60
+ })
61
+ })
62
+ })
63
+
64
+ describe("formatErrorString", () => {
65
+ it("returns JSON-stringified content for short strings", () => {
66
+ const result = formatErrorString("short text", "test")
67
+ expect(result).toBe('"short text"')
68
+ })
69
+
70
+ it("writes to temp file for strings over 500 chars", () => {
71
+ const longText = "x".repeat(501)
72
+ const result = formatErrorString(longText, "long-test")
73
+
74
+ expect(result).toMatch(/^\[written to .+punctilio-error-long-test-\d+\.txt\]$/)
75
+
76
+ // Extract filepath and verify file contents
77
+ const match = result.match(/\[written to (?<path>.+)\]/)
78
+ const filepath = match?.groups?.path ?? ""
79
+ expect(filepath).not.toBe("")
80
+ expect(existsSync(filepath)).toBe(true)
81
+ expect(readFileSync(filepath, "utf-8")).toBe(longText)
82
+
83
+ // Cleanup
84
+ unlinkSync(filepath)
85
+ })
86
+
87
+ it("writes to temp directory", () => {
88
+ const longText = "y".repeat(600)
89
+ const result = formatErrorString(longText, "tmpdir")
90
+
91
+ const match = result.match(/\[written to (?<path>.+)\]/)
92
+ const filepath = match?.groups?.path ?? ""
93
+ expect(filepath).not.toBe("")
94
+ expect(filepath.startsWith(tmpdir())).toBe(true)
95
+
96
+ unlinkSync(filepath)
97
+ })
98
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Utility functions for text transformation validation.
3
+ *
4
+ * @module utils
5
+ */
6
+
7
+ import { writeFileSync } from "node:fs"
8
+ import { tmpdir } from "node:os"
9
+ import { join } from "node:path"
10
+
11
+ import { DEFAULT_SEPARATOR } from "./constants.js"
12
+
13
+ /** Threshold above which strings are written to temp files in error messages. */
14
+ const ERROR_STRING_THRESHOLD = 500
15
+
16
+ /**
17
+ * Formats a string for error messages. If the string exceeds the threshold,
18
+ * writes it to a temp file and returns a reference to the file path.
19
+ */
20
+ export function formatErrorString(content: string, label: string): string {
21
+ if (content.length <= ERROR_STRING_THRESHOLD) {
22
+ return JSON.stringify(content)
23
+ }
24
+
25
+ const timestamp = Date.now()
26
+ const filename = `punctilio-error-${label}-${timestamp}.txt`
27
+ const filepath = join(tmpdir(), filename)
28
+ writeFileSync(filepath, content, "utf-8")
29
+ return `[written to ${filepath}]`
30
+ }
31
+
32
+ export function countSeparators(text: string, separator: string = DEFAULT_SEPARATOR): number {
33
+ let count = 0
34
+ for (const char of text) {
35
+ if (char === separator) count++
36
+ }
37
+ return count
38
+ }
39
+
40
+ /**
41
+ * Validates that a transformation preserved the separator count.
42
+ * Throws an error if separators were added or removed.
43
+ *
44
+ * @param original - The original text before transformation
45
+ * @param transformed - The text after transformation
46
+ * @param separator - The separator character to check (default: DEFAULT_SEPARATOR)
47
+ * @param transformName - Name of the transform for error messages (default: "transform")
48
+ * @throws Error if separator count changed
49
+ */
50
+ export function assertSeparatorCountPreserved(
51
+ original: string,
52
+ transformed: string,
53
+ separator: string = DEFAULT_SEPARATOR,
54
+ transformName: string = "transform"
55
+ ): void {
56
+ const originalCount = countSeparators(original, separator)
57
+ const transformedCount = countSeparators(transformed, separator)
58
+ if (originalCount !== transformedCount) {
59
+ throw new Error(
60
+ `${transformName} altered separator count: expected ${originalCount}, got ${transformedCount}.\n` +
61
+ `This is a bug in punctilio. Please file an issue at https://github.com/alexander-turner/punctilio/issues\n` +
62
+ `Include the input text that caused this error.`
63
+ )
64
+ }
65
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
20
+ }