tjs-lang 0.7.6 → 0.7.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tjs-lang",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Type-safe JavaScript dialect with runtime validation, sandboxed VM execution, and AI agent orchestration. Transpiles TypeScript to validated JS with fuel-metered execution for untrusted code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -947,6 +947,46 @@ function isEqual(! a: null, b: null):! true { return a == b }`
947
947
  // No coercion: 0 != '' (unlike JS ==)
948
948
  expect(isEqual(0, '')).toBe(false)
949
949
  })
950
+
951
+ it('typeof transform skips string/template/comment bodies (regression)', () => {
952
+ // Previously the typeof→TypeOf rewrite was a naive regex that also
953
+ // mangled `'typeof x'` inside string literals. The replacement is now
954
+ // tokenizer-aware.
955
+ const tjsSource = `
956
+ const label = 'typeof null is null'
957
+ const tpl = \`type marker: typeof x\`
958
+ // typeof y
959
+ const t = typeof null
960
+ `
961
+ const { code } = tjs(tjsSource)
962
+ expect(code).toContain("'typeof null is null'")
963
+ expect(code).toContain('`type marker: typeof x`')
964
+ expect(code).toContain('TypeOf(null)')
965
+ })
966
+
967
+ it('applies TjsEquals to test/mock bodies (regression)', () => {
968
+ // Native TJS has TjsEquals on by default. Test bodies are extracted as
969
+ // raw text before parsing, so == inside them must also be transformed.
970
+ const tjsSource = `const foo = '' == false
971
+
972
+ test 'top-level == is transformed' {
973
+ expect(foo).toBe(false)
974
+ }
975
+
976
+ test '== inside test block is transformed' {
977
+ expect('' == false).toBe(false)
978
+ expect('' != false).toBe(true)
979
+ }
980
+
981
+ test '=== identity is preserved' {
982
+ expect('' === false).toBe(false)
983
+ expect(0 === 0).toBe(true)
984
+ }
985
+ `
986
+ const { testResults } = tjs(tjsSource, { runTests: 'report' })
987
+ const failures = (testResults || []).filter((r) => !r.passed)
988
+ expect(failures).toEqual([])
989
+ })
950
990
  })
951
991
 
952
992
  describe('TjsStandard (ASI protection)', () => {
@@ -657,6 +657,12 @@ export function runAllTests(
657
657
  // Build mock setup
658
658
  const mockSetup = mocks.map((m) => m.body).join('\n')
659
659
 
660
+ // Test bodies may reference Is/IsNot/Eq/NotEq/TypeOf — these come from the
661
+ // == / != / typeof source-level transforms applied to test bodies in js.ts.
662
+ // The module's own destructuring may not include them (if the module never
663
+ // uses ==), so each test block re-destructures into its own block scope.
664
+ const testRuntimeImports = `const { Is, IsNot, Eq, NotEq, TypeOf } = globalThis.__tjs ?? {};`
665
+
660
666
  // Build test execution code that runs all tests in sequence
661
667
  const testBodies = tests
662
668
  .map((t, i) => {
@@ -668,6 +674,7 @@ export function runAllTests(
668
674
  return `
669
675
  // Test ${i}: ${t.description}
670
676
  try {
677
+ ${testRuntimeImports}
671
678
  ${body}
672
679
  __testResults.push({ idx: ${i}, passed: true });
673
680
  } catch (e) {
@@ -53,7 +53,12 @@ import {
53
53
  extractTDoc,
54
54
  preprocess,
55
55
  transformExtensionCalls,
56
+ stripLineComments,
56
57
  } from '../parser'
58
+ import {
59
+ transformEqualityToStructural,
60
+ transformIsOperators,
61
+ } from '../parser-transforms'
57
62
  import type { TypeDescriptor, ParameterDescriptor } from '../types'
58
63
  import { inferTypeFromValue, parseParameter } from '../inference'
59
64
  import { extractTests } from '../tests'
@@ -576,6 +581,10 @@ export function transpileToJS(
576
581
  } = options
577
582
  const warnings: string[] = []
578
583
 
584
+ // Strip single-line comments early — apostrophes in comments (e.g. "don't")
585
+ // confuse brace matching in test extraction and other transforms
586
+ source = stripLineComments(source)
587
+
579
588
  // Extract source file annotation if present (from TS transpilation)
580
589
  const sourceFileAnnotation = extractSourceFileAnnotation(source)
581
590
  const effectiveFilename = sourceFileAnnotation || filename
@@ -600,6 +609,23 @@ export function transpileToJS(
600
609
  // Preprocess source (handles TJS syntax transformations)
601
610
  const preprocessed = preprocess(cleanSource)
602
611
 
612
+ // Apply the same source-level equality transforms to extracted test/mock
613
+ // bodies so they observe the module's TJS semantics (e.g. structural ==).
614
+ // Test bodies are extracted as raw text before parse(), so they would
615
+ // otherwise run with native JS == coercion regardless of TjsEquals mode.
616
+ for (const t of tests) {
617
+ t.body = transformIsOperators(t.body)
618
+ if (preprocessed.tjsModes.tjsEquals) {
619
+ t.body = transformEqualityToStructural(t.body)
620
+ }
621
+ }
622
+ for (const m of mocks) {
623
+ m.body = transformIsOperators(m.body)
624
+ if (preprocessed.tjsModes.tjsEquals) {
625
+ m.body = transformEqualityToStructural(m.body)
626
+ }
627
+ }
628
+
603
629
  // Build types map for all functions
604
630
  const allTypes: Record<string, TJSTypeInfo> = {}
605
631
 
@@ -153,6 +153,28 @@ describe('Inline Tests', () => {
153
153
  expect(result.tests[0].body).toContain('assert')
154
154
  })
155
155
 
156
+ it('should extract test descriptions containing other quote types', () => {
157
+ // Regression: previously the regex excluded all quote chars from
158
+ // descriptions, so `test 'has "quotes"' { }` was silently dropped.
159
+ const result = extractTests(`
160
+ test 'typeof null is "null"' {
161
+ assert(true)
162
+ }
163
+ test "single 'apostrophe' inside" {
164
+ assert(true)
165
+ }
166
+ test \`backticks with "double" and 'single'\` {
167
+ assert(true)
168
+ }
169
+ `)
170
+ expect(result.tests.length).toBe(3)
171
+ expect(result.tests[0].description).toBe('typeof null is "null"')
172
+ expect(result.tests[1].description).toBe("single 'apostrophe' inside")
173
+ expect(result.tests[2].description).toBe(
174
+ `backticks with "double" and 'single'`
175
+ )
176
+ })
177
+
156
178
  it('should remove tests from output code', () => {
157
179
  const result = extractTests(`
158
180
  function add(a, b) { return a + b }
@@ -593,6 +593,201 @@ export function insertAsiProtection(source: string): string {
593
593
  return result.join('\n')
594
594
  }
595
595
 
596
+ /**
597
+ * Replace `typeof X` with `TypeOf(X)` outside string/template/comment/regex bodies.
598
+ * X may be `ident`, `ident.foo`, `ident?.foo`, etc. Anything that doesn't look
599
+ * like a simple identifier expression (e.g. `typeof (x + 1)`) is left alone.
600
+ */
601
+ function transformTypeofKeyword(source: string): string {
602
+ type Match = { keywordStart: number; operandEnd: number; operand: string }
603
+ const matches: Match[] = []
604
+ let i = 0
605
+ let state: TokenizerState = 'normal'
606
+ const templateStack: number[] = []
607
+
608
+ while (i < source.length) {
609
+ const char = source[i]
610
+ const nextChar = source[i + 1]
611
+
612
+ switch (state) {
613
+ case 'single-string':
614
+ if (char === '\\' && i + 1 < source.length) {
615
+ i += 2
616
+ continue
617
+ }
618
+ if (char === "'") state = 'normal'
619
+ i++
620
+ continue
621
+ case 'double-string':
622
+ if (char === '\\' && i + 1 < source.length) {
623
+ i += 2
624
+ continue
625
+ }
626
+ if (char === '"') state = 'normal'
627
+ i++
628
+ continue
629
+ case 'template-string':
630
+ if (char === '\\' && i + 1 < source.length) {
631
+ i += 2
632
+ continue
633
+ }
634
+ if (char === '$' && nextChar === '{') {
635
+ i += 2
636
+ templateStack.push(1)
637
+ state = 'normal'
638
+ continue
639
+ }
640
+ if (char === '`') state = 'normal'
641
+ i++
642
+ continue
643
+ case 'line-comment':
644
+ if (char === '\n') state = 'normal'
645
+ i++
646
+ continue
647
+ case 'block-comment':
648
+ if (char === '*' && nextChar === '/') {
649
+ i += 2
650
+ state = 'normal'
651
+ continue
652
+ }
653
+ i++
654
+ continue
655
+ case 'regex':
656
+ if (char === '\\' && i + 1 < source.length) {
657
+ i += 2
658
+ continue
659
+ }
660
+ if (char === '[') {
661
+ i++
662
+ while (i < source.length && source[i] !== ']') {
663
+ if (source[i] === '\\' && i + 1 < source.length) i += 2
664
+ else i++
665
+ }
666
+ if (i < source.length) i++
667
+ continue
668
+ }
669
+ if (char === '/') {
670
+ i++
671
+ while (i < source.length && /[gimsuy]/.test(source[i])) i++
672
+ state = 'normal'
673
+ continue
674
+ }
675
+ i++
676
+ continue
677
+ case 'normal':
678
+ if (templateStack.length > 0) {
679
+ if (char === '{') {
680
+ templateStack[templateStack.length - 1]++
681
+ } else if (char === '}') {
682
+ templateStack[templateStack.length - 1]--
683
+ if (templateStack[templateStack.length - 1] === 0) {
684
+ templateStack.pop()
685
+ i++
686
+ state = 'template-string'
687
+ continue
688
+ }
689
+ }
690
+ }
691
+ if (char === "'") {
692
+ i++
693
+ state = 'single-string'
694
+ continue
695
+ }
696
+ if (char === '"') {
697
+ i++
698
+ state = 'double-string'
699
+ continue
700
+ }
701
+ if (char === '`') {
702
+ i++
703
+ state = 'template-string'
704
+ continue
705
+ }
706
+ if (char === '/' && nextChar === '/') {
707
+ i += 2
708
+ state = 'line-comment'
709
+ continue
710
+ }
711
+ if (char === '/' && nextChar === '*') {
712
+ i += 2
713
+ state = 'block-comment'
714
+ continue
715
+ }
716
+ if (char === '/') {
717
+ let j = i - 1
718
+ while (j >= 0 && /\s/.test(source[j])) j--
719
+ const beforeChar = j >= 0 ? source[j] : ''
720
+ const isRegexContext =
721
+ !beforeChar ||
722
+ /[=(!,;:{[&|?+\-*%<>~^]/.test(beforeChar) ||
723
+ (j >= 5 &&
724
+ /\b(return|case|throw|in|of|typeof|instanceof|new|delete|void)$/.test(
725
+ source.slice(Math.max(0, j - 10), j + 1)
726
+ ))
727
+ if (isRegexContext) {
728
+ i++
729
+ state = 'regex'
730
+ continue
731
+ }
732
+ }
733
+
734
+ // Detect `typeof <ident-chain>` — only when preceded by a non-word
735
+ // boundary and followed by whitespace then an identifier.
736
+ if (
737
+ char === 't' &&
738
+ source.slice(i, i + 6) === 'typeof' &&
739
+ (i === 0 || !/[\w$]/.test(source[i - 1])) &&
740
+ /\s/.test(source[i + 6] ?? '')
741
+ ) {
742
+ let j = i + 6
743
+ while (j < source.length && /\s/.test(source[j])) j++
744
+ if (j < source.length && /[a-zA-Z_$]/.test(source[j])) {
745
+ const operandStart = j
746
+ while (j < source.length && /[\w$]/.test(source[j])) j++
747
+ // Optional `.name` or `?.name` chains
748
+ while (j < source.length) {
749
+ if (source[j] === '.' && /[a-zA-Z_$]/.test(source[j + 1] ?? '')) {
750
+ j++
751
+ while (j < source.length && /[\w$]/.test(source[j])) j++
752
+ } else if (
753
+ source[j] === '?' &&
754
+ source[j + 1] === '.' &&
755
+ /[a-zA-Z_$]/.test(source[j + 2] ?? '')
756
+ ) {
757
+ j += 2
758
+ while (j < source.length && /[\w$]/.test(source[j])) j++
759
+ } else {
760
+ break
761
+ }
762
+ }
763
+ matches.push({
764
+ keywordStart: i,
765
+ operandEnd: j,
766
+ operand: source.slice(operandStart, j),
767
+ })
768
+ i = j
769
+ continue
770
+ }
771
+ }
772
+ break
773
+ }
774
+ i++
775
+ }
776
+
777
+ if (matches.length === 0) return source
778
+
779
+ // Apply replacements from end to start so earlier positions remain valid.
780
+ let result = source
781
+ for (let k = matches.length - 1; k >= 0; k--) {
782
+ const m = matches[k]
783
+ result =
784
+ result.slice(0, m.keywordStart) +
785
+ `TypeOf(${m.operand})` +
786
+ result.slice(m.operandEnd)
787
+ }
788
+ return result
789
+ }
790
+
596
791
  /**
597
792
  * Transform == and != to Is() and IsNot() calls
598
793
  *
@@ -606,11 +801,11 @@ export function insertAsiProtection(source: string): string {
606
801
  * 2. Transform from end to start (so positions remain valid)
607
802
  */
608
803
  export function transformEqualityToStructural(source: string): string {
609
- // Transform typeof to TypeOf() — fixes typeof null === 'object'
610
- source = source.replace(
611
- /\btypeof\s+([a-zA-Z_$][\w$.]*(?:\?\.[\w$]+)*)/g,
612
- 'TypeOf($1)'
613
- )
804
+ // Transform typeof to TypeOf() — fixes typeof null === 'object'.
805
+ // Uses the same tokenizer state machine as the equality pass so it skips
806
+ // string/template/comment/regex bodies (a regex replace would rewrite
807
+ // 'typeof x' inside string literals).
808
+ source = transformTypeofKeyword(source)
614
809
 
615
810
  // First pass: find all == and != positions (outside strings/comments/regex)
616
811
  const equalityOps: Array<{ pos: number; op: '==' | '!=' }> = []
@@ -2642,9 +2837,43 @@ export function extractAndRunTests(
2642
2837
  let depth = 1
2643
2838
  let k = bodyStart
2644
2839
 
2645
- // Find matching closing brace
2840
+ // Find matching closing brace (skip strings and comments)
2841
+ let inStr: string | null = null
2842
+ let escaped = false
2646
2843
  while (k < source.length && depth > 0) {
2647
2844
  const char = source[k]
2845
+ if (escaped) {
2846
+ escaped = false
2847
+ k++
2848
+ continue
2849
+ }
2850
+ if (char === '\\' && inStr) {
2851
+ escaped = true
2852
+ k++
2853
+ continue
2854
+ }
2855
+ if (inStr) {
2856
+ if (char === inStr) inStr = null
2857
+ k++
2858
+ continue
2859
+ }
2860
+ // Line comment — skip to end of line
2861
+ if (char === '/' && source[k + 1] === '/') {
2862
+ const nl = source.indexOf('\n', k)
2863
+ k = nl === -1 ? source.length : nl + 1
2864
+ continue
2865
+ }
2866
+ // Block comment — skip to */
2867
+ if (char === '/' && source[k + 1] === '*') {
2868
+ const end = source.indexOf('*/', k + 2)
2869
+ k = end === -1 ? source.length : end + 2
2870
+ continue
2871
+ }
2872
+ if (char === "'" || char === '"' || char === '`') {
2873
+ inStr = char
2874
+ k++
2875
+ continue
2876
+ }
2648
2877
  if (char === '{') depth++
2649
2878
  else if (char === '}') depth--
2650
2879
  k++
@@ -56,6 +56,52 @@ import {
56
56
  // Re-export transformExtensionCalls for js.ts
57
57
  export { transformExtensionCalls } from './parser-transforms'
58
58
 
59
+ /**
60
+ * Strip single-line comments (//) from source.
61
+ * Replaces comment content with spaces to preserve character offsets.
62
+ * Skips // inside strings and block comments.
63
+ */
64
+ export function stripLineComments(source: string): string {
65
+ let result = ''
66
+ let i = 0
67
+ while (i < source.length) {
68
+ const ch = source[i]
69
+ // String literals — skip to closing quote
70
+ if (ch === "'" || ch === '"' || ch === '`') {
71
+ const quote = ch
72
+ result += ch
73
+ i++
74
+ while (i < source.length && source[i] !== quote) {
75
+ if (source[i] === '\\') {
76
+ result += source[i++]
77
+ }
78
+ if (i < source.length) result += source[i++]
79
+ }
80
+ if (i < source.length) result += source[i++] // closing quote
81
+ continue
82
+ }
83
+ // Block comment — pass through (may contain //)
84
+ if (ch === '/' && source[i + 1] === '*') {
85
+ const end = source.indexOf('*/', i + 2)
86
+ const slice = end === -1 ? source.slice(i) : source.slice(i, end + 2)
87
+ result += slice
88
+ i += slice.length
89
+ continue
90
+ }
91
+ // Line comment — replace with spaces to preserve offsets
92
+ if (ch === '/' && source[i + 1] === '/') {
93
+ const nl = source.indexOf('\n', i)
94
+ const end = nl === -1 ? source.length : nl
95
+ result += ' '.repeat(end - i)
96
+ i = end // leave \n for next iteration
97
+ continue
98
+ }
99
+ result += ch
100
+ i++
101
+ }
102
+ return result
103
+ }
104
+
59
105
  export function preprocess(
60
106
  source: string,
61
107
  options: PreprocessOptions = {}
@@ -182,6 +228,11 @@ export function preprocess(
182
228
  )
183
229
  }
184
230
 
231
+ // Strip single-line comments early — they confuse brace matching,
232
+ // ASI protection, and test extraction (e.g. apostrophes in comments)
233
+ // Preserves line structure by keeping the newline
234
+ source = stripLineComments(source)
235
+
185
236
  // TjsStandard mode: insert semicolons to prevent ASI footguns
186
237
  // Must happen early before other transformations modify line structure
187
238
  if (tjsModes.tjsStandard) {
package/src/lang/tests.ts CHANGED
@@ -106,15 +106,17 @@ function extractEmbeddedTests(source: string): ExtractedTest[] {
106
106
  const tests: ExtractedTest[] = []
107
107
 
108
108
  // Match: /*test 'description' { ... }*/ or /*test { ... }*/
109
+ // Each quote type gets its own alternative so the description can contain
110
+ // the other quote types (e.g. `test 'typeof null is "null"' {`).
109
111
  const embeddedRegex =
110
- /\/\*test\s+(['"`])([^'"`]*)\1\s*\{([\s\S]*?)\}\s*\*\/|\/\*test\s*\{([\s\S]*?)\}\s*\*\//g
112
+ /\/\*test\s+'([^']*)'\s*\{([\s\S]*?)\}\s*\*\/|\/\*test\s+"([^"]*)"\s*\{([\s\S]*?)\}\s*\*\/|\/\*test\s+`([^`]*)`\s*\{([\s\S]*?)\}\s*\*\/|\/\*test\s*\{([\s\S]*?)\}\s*\*\//g
111
113
 
112
114
  let match
113
115
  while ((match = embeddedRegex.exec(source)) !== null) {
114
- // Group 2 = description for quoted, group 3 = body for quoted
115
- // Group 4 = body for anonymous
116
- const desc = match[2] || `embedded test ${tests.length + 1}`
117
- const body = (match[3] || match[4] || '').trim()
116
+ // Groups: 1/3/5 = description for ' " ` ; 2/4/6 = body for ' " ` ; 7 = body for anonymous
117
+ const desc =
118
+ match[1] || match[3] || match[5] || `embedded test ${tests.length + 1}`
119
+ const body = (match[2] || match[4] || match[6] || match[7] || '').trim()
118
120
 
119
121
  tests.push({
120
122
  description: desc,
@@ -145,8 +147,10 @@ export function extractTests(source: string): TestExtractionResult {
145
147
  // test { ... } (anonymous test)
146
148
  // test 'description' { ... } (canonical TJS)
147
149
  // test('description') { ... } (also valid - parenthesized string is still a string)
150
+ // Each quote type has its own alternative so the description can contain
151
+ // the other quote types (e.g. `test 'typeof null is "null"' {`).
148
152
  const testRegex =
149
- /test\s+(['"`])([^'"`]*)\1\s*\{|test\s*\(\s*(['"`])([^'"`]*)\3\s*\)\s*\{|test\s*\{/g
153
+ /test\s+'([^']*)'\s*\{|test\s+"([^"]*)"\s*\{|test\s+`([^`]*)`\s*\{|test\s*\(\s*'([^']*)'\s*\)\s*\{|test\s*\(\s*"([^"]*)"\s*\)\s*\{|test\s*\(\s*`([^`]*)`\s*\)\s*\{|test\s*\{/g
150
154
  const mockRegex = /mock\s*\{/g
151
155
 
152
156
  let cleanCode = source
@@ -164,8 +168,17 @@ export function extractTests(source: string): TestExtractionResult {
164
168
  continue
165
169
  }
166
170
 
167
- // Description is in group 2 for `test 'desc'`, group 4 for `test('desc')`, or undefined for `test {`
168
- const desc = match[2] || match[4] || `test ${tests.length + 1}`
171
+ // Groups 1/2/3 = `test 'desc'` / `test "desc"` / `test \`desc\``
172
+ // Groups 4/5/6 = parenthesized variants
173
+ // No group when description is omitted
174
+ const desc =
175
+ match[1] ||
176
+ match[2] ||
177
+ match[3] ||
178
+ match[4] ||
179
+ match[5] ||
180
+ match[6] ||
181
+ `test ${tests.length + 1}`
169
182
  const bodyStart = match.index + match[0].length
170
183
 
171
184
  // Find matching closing brace