punctilio 1.3.1 → 1.4.1

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,128 @@
1
+ import { countSeparators, assertSeparatorCountPreserved, formatErrorString } from "../utils.js"
2
+ import { DEFAULT_SEPARATOR } from "../constants.js"
3
+
4
+ describe("countSeparators", () => {
5
+ it.each([
6
+ ["empty string", "", DEFAULT_SEPARATOR, 0],
7
+ ["no separators", "hello world", DEFAULT_SEPARATOR, 0],
8
+ ["single separator", `a${DEFAULT_SEPARATOR}b`, DEFAULT_SEPARATOR, 1],
9
+ ["multiple separators", `${DEFAULT_SEPARATOR}a${DEFAULT_SEPARATOR}b${DEFAULT_SEPARATOR}`, DEFAULT_SEPARATOR, 3],
10
+ ["consecutive separators", `${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`, DEFAULT_SEPARATOR, 3],
11
+ ["custom separator", "a|b|c", "|", 2],
12
+ ])("%s: counts correctly", (_desc, input, separator, expected) => {
13
+ expect(countSeparators(input, separator)).toBe(expected)
14
+ })
15
+
16
+ it.each([
17
+ [`a${DEFAULT_SEPARATOR}b`, 1],
18
+ ["no separators", 0],
19
+ [`${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`, 2],
20
+ ])("uses DEFAULT_SEPARATOR when omitted: %s -> %i", (input, expected) => {
21
+ expect(countSeparators(input)).toBe(expected)
22
+ })
23
+ })
24
+
25
+ describe("assertSeparatorCountPreserved", () => {
26
+ describe("does not throw when count preserved", () => {
27
+ it.each([
28
+ ["same count", `a${DEFAULT_SEPARATOR}b`, `x${DEFAULT_SEPARATOR}y`],
29
+ ["zero count", "ab", "xy"],
30
+ ["multiple preserved", `${DEFAULT_SEPARATOR}a${DEFAULT_SEPARATOR}b${DEFAULT_SEPARATOR}`, `${DEFAULT_SEPARATOR}x${DEFAULT_SEPARATOR}y${DEFAULT_SEPARATOR}`],
31
+ ])("%s", (_desc, original, transformed) => {
32
+ expect(() => assertSeparatorCountPreserved(original, transformed, DEFAULT_SEPARATOR, "test")).not.toThrow()
33
+ })
34
+ })
35
+
36
+ describe("throws when count changes", () => {
37
+ it.each([
38
+ ["separator removed", `a${DEFAULT_SEPARATOR}b`, "ab", 1, 0],
39
+ ["separator added", "ab", `a${DEFAULT_SEPARATOR}b`, 0, 1],
40
+ ["multiple removed", `${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`, "", 2, 0],
41
+ ])("%s: expected %i, got %i", (_desc, original, transformed, expectedCount, actualCount) => {
42
+ expect(() => assertSeparatorCountPreserved(original, transformed, DEFAULT_SEPARATOR, "testFn"))
43
+ .toThrow(`testFn altered separator count: expected ${expectedCount}, got ${actualCount}`)
44
+ })
45
+ })
46
+
47
+ describe("default parameters", () => {
48
+ it.each([
49
+ ["throws when separator lost", `a${DEFAULT_SEPARATOR}b`, "ab", true],
50
+ ["does not throw when preserved", `a${DEFAULT_SEPARATOR}b`, `x${DEFAULT_SEPARATOR}y`, false],
51
+ ])("%s", (_desc, original, transformed, shouldThrow) => {
52
+ if (shouldThrow) {
53
+ expect(() => assertSeparatorCountPreserved(original, transformed)).toThrow()
54
+ } else {
55
+ expect(() => assertSeparatorCountPreserved(original, transformed)).not.toThrow()
56
+ }
57
+ })
58
+ })
59
+ })
60
+
61
+ describe("formatErrorString", () => {
62
+ it("returns JSON-stringified content for short strings", () => {
63
+ const result = formatErrorString("short text", "test")
64
+ expect(result).toBe('"short text"')
65
+ })
66
+
67
+ it("truncates strings over 2000 chars with label and length", () => {
68
+ const longText = "x".repeat(2001)
69
+ const result = formatErrorString(longText, "long-test")
70
+
71
+ expect(result).toContain("[long-test:")
72
+ expect(result).toContain("2001 chars total")
73
+ expect(result).toContain("...")
74
+ })
75
+
76
+ it("includes first 2000 chars in truncated output", () => {
77
+ const longText = "a".repeat(1500) + "b".repeat(1000)
78
+ const result = formatErrorString(longText, "mixed")
79
+
80
+ // Should contain all 1500 a's and first 500 b's (2000 total)
81
+ const expected2000 = "a".repeat(1500) + "b".repeat(500)
82
+ expect(result).toContain(JSON.stringify(expected2000))
83
+ expect(result).toContain("2500 chars total")
84
+ })
85
+
86
+ it("writes full content to stderr in Node.js for long strings", () => {
87
+ const originalWrite = process.stderr.write
88
+ const written: string[] = []
89
+ process.stderr.write = ((chunk: string) => {
90
+ written.push(chunk)
91
+ return true
92
+ }) as typeof process.stderr.write
93
+
94
+ try {
95
+ const longText = "z".repeat(2001)
96
+ formatErrorString(longText, "stderr-test")
97
+
98
+ expect(written.length).toBe(1)
99
+ expect(written[0]).toContain("punctilio stderr-test full content")
100
+ expect(written[0]).toContain(longText)
101
+ } finally {
102
+ process.stderr.write = originalWrite
103
+ }
104
+ })
105
+
106
+ it("does not write to stderr for short strings", () => {
107
+ const originalWrite = process.stderr.write
108
+ const written: string[] = []
109
+ process.stderr.write = ((chunk: string) => {
110
+ written.push(chunk)
111
+ return true
112
+ }) as typeof process.stderr.write
113
+
114
+ try {
115
+ formatErrorString("short", "test")
116
+ expect(written.length).toBe(0)
117
+ } finally {
118
+ process.stderr.write = originalWrite
119
+ }
120
+ })
121
+
122
+ it("handles strings at exactly the threshold", () => {
123
+ const exactText = "x".repeat(2000)
124
+ const result = formatErrorString(exactText, "exact")
125
+ // At threshold, should return JSON-stringified (not truncated)
126
+ expect(result).toBe(JSON.stringify(exactText))
127
+ })
128
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Utility functions for text transformation validation.
3
+ *
4
+ * @module utils
5
+ */
6
+
7
+ import { DEFAULT_SEPARATOR } from "./constants.js"
8
+
9
+ /** Threshold above which strings are truncated in error messages. */
10
+ const ERROR_STRING_THRESHOLD = 2000
11
+
12
+ /**
13
+ * Formats a string for error messages. If the string exceeds the threshold,
14
+ * truncates it and shows the total length.
15
+ *
16
+ * In Node.js environments, also writes the full content to stderr
17
+ * so it's available in build logs for debugging.
18
+ */
19
+ export function formatErrorString(content: string, label: string): string {
20
+ if (content.length <= ERROR_STRING_THRESHOLD) {
21
+ return JSON.stringify(content)
22
+ }
23
+
24
+ // In Node.js, write full content to stderr for debugging
25
+ try {
26
+ if (typeof globalThis.process?.stderr?.write === "function") {
27
+ globalThis.process.stderr.write(
28
+ `\n[punctilio ${label} full content (${content.length} chars)]:\n${content}\n\n`
29
+ )
30
+ }
31
+ } catch {
32
+ // Ignore — not in Node.js or stderr unavailable
33
+ }
34
+
35
+ const truncated = content.slice(0, ERROR_STRING_THRESHOLD)
36
+ return `[${label}: ${JSON.stringify(truncated)}... (${content.length} chars total)]`
37
+ }
38
+
39
+ export function countSeparators(text: string, separator: string = DEFAULT_SEPARATOR): number {
40
+ let count = 0
41
+ for (const char of text) {
42
+ if (char === separator) count++
43
+ }
44
+ return count
45
+ }
46
+
47
+ /**
48
+ * Validates that a transformation preserved the separator count.
49
+ * Throws an error if separators were added or removed.
50
+ *
51
+ * @param original - The original text before transformation
52
+ * @param transformed - The text after transformation
53
+ * @param separator - The separator character to check (default: DEFAULT_SEPARATOR)
54
+ * @param transformName - Name of the transform for error messages (default: "transform")
55
+ * @throws Error if separator count changed
56
+ */
57
+ export function assertSeparatorCountPreserved(
58
+ original: string,
59
+ transformed: string,
60
+ separator: string = DEFAULT_SEPARATOR,
61
+ transformName: string = "transform"
62
+ ): void {
63
+ const originalCount = countSeparators(original, separator)
64
+ const transformedCount = countSeparators(transformed, separator)
65
+ if (originalCount !== transformedCount) {
66
+ throw new Error(
67
+ `${transformName} altered separator count: expected ${originalCount}, got ${transformedCount}.\n` +
68
+ `This is a bug in punctilio. Please file an issue at https://github.com/alexander-turner/punctilio/issues\n` +
69
+ `Include the input text that caused this error.`
70
+ )
71
+ }
72
+ }
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
+ }