@voxgig/apidef 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/apidef.d.ts +3 -29
  2. package/dist/apidef.js +84 -186
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/apiEntity.d.ts +3 -0
  5. package/dist/builder/entity/apiEntity.js +51 -0
  6. package/dist/builder/entity/apiEntity.js.map +1 -0
  7. package/dist/builder/entity/def.d.ts +3 -0
  8. package/dist/builder/entity/def.js +19 -0
  9. package/dist/builder/entity/def.js.map +1 -0
  10. package/dist/builder/entity.d.ts +2 -0
  11. package/dist/builder/entity.js +30 -0
  12. package/dist/builder/entity.js.map +1 -0
  13. package/dist/builder/flow/flowHeuristic01.d.ts +2 -0
  14. package/dist/builder/flow/flowHeuristic01.js +125 -0
  15. package/dist/builder/flow/flowHeuristic01.js.map +1 -0
  16. package/dist/builder/flow.d.ts +2 -0
  17. package/dist/builder/flow.js +41 -0
  18. package/dist/builder/flow.js.map +1 -0
  19. package/dist/guide/heuristic01.d.ts +2 -0
  20. package/dist/guide/heuristic01.js +178 -0
  21. package/dist/guide/heuristic01.js.map +1 -0
  22. package/dist/guide.d.ts +2 -0
  23. package/dist/guide.js +62 -0
  24. package/dist/guide.js.map +1 -0
  25. package/dist/parse.d.ts +1 -1
  26. package/dist/parse.js +5 -4
  27. package/dist/parse.js.map +1 -1
  28. package/dist/resolver.d.ts +2 -0
  29. package/dist/resolver.js +62 -0
  30. package/dist/resolver.js.map +1 -0
  31. package/dist/transform/entity.js +4 -2
  32. package/dist/transform/entity.js.map +1 -1
  33. package/dist/transform/field.js +4 -84
  34. package/dist/transform/field.js.map +1 -1
  35. package/dist/transform/operation.d.ts +2 -2
  36. package/dist/transform/operation.js +22 -11
  37. package/dist/transform/operation.js.map +1 -1
  38. package/dist/transform/top.d.ts +2 -2
  39. package/dist/transform/top.js +5 -4
  40. package/dist/transform/top.js.map +1 -1
  41. package/dist/transform.d.ts +1 -1
  42. package/dist/transform.js +21 -10
  43. package/dist/transform.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/dist/types.d.ts +682 -0
  46. package/dist/types.js +38 -0
  47. package/dist/types.js.map +1 -0
  48. package/dist/utility.d.ts +4 -0
  49. package/dist/utility.js +59 -0
  50. package/dist/utility.js.map +1 -0
  51. package/model/apidef.jsonic +28 -24
  52. package/package.json +11 -9
  53. package/src/apidef.ts +117 -263
  54. package/src/builder/entity/apiEntity.ts +88 -0
  55. package/src/builder/entity/def.ts +44 -0
  56. package/src/builder/entity.ts +54 -0
  57. package/src/builder/flow/flowHeuristic01.ts +165 -0
  58. package/src/builder/flow/flowHeuristic01.ts~ +45 -0
  59. package/src/builder/flow.ts +60 -0
  60. package/src/guide/heuristic01.ts +225 -0
  61. package/src/guide.ts +91 -0
  62. package/src/parse.ts +6 -4
  63. package/src/resolver.ts +91 -0
  64. package/src/transform/entity.ts +10 -7
  65. package/src/transform/field.ts +9 -92
  66. package/src/transform/operation.ts +39 -25
  67. package/src/transform/top.ts +11 -9
  68. package/src/transform.ts +23 -11
  69. package/src/types.ts +88 -0
  70. package/src/utility.ts +83 -0
  71. package/dist/transform/manual.d.ts +0 -3
  72. package/dist/transform/manual.js +0 -12
  73. package/dist/transform/manual.js.map +0 -1
  74. package/src/transform/manual.ts +0 -29
@@ -0,0 +1,88 @@
1
+ /* Copyright (c) 2025 Voxgig, MIT License */
2
+
3
+ import Path from 'node:path'
4
+
5
+ import { each, File, Folder, Content } from 'jostraca'
6
+
7
+
8
+ import type {
9
+ ApiDefOptions,
10
+ } from '../../types'
11
+
12
+ import {
13
+ formatJsonSrc,
14
+ } from '../../utility'
15
+
16
+
17
+
18
+ function resolveApiEntity(
19
+ apimodel: any,
20
+ opts: ApiDefOptions,
21
+ ) {
22
+ const barrel = [
23
+ '# Entity Models\n'
24
+ ]
25
+
26
+ const entityFiles: { name: string, src: string }[] = []
27
+
28
+ each(apimodel.main.api.entity, ((entity: any, entityName: string) => {
29
+ const entityFile = (null == opts.outprefix ? '' : opts.outprefix) + entityName + '.jsonic'
30
+
31
+ const entityJSON =
32
+ JSON.stringify(entity, null, 2)
33
+
34
+ const fieldAliasesSrc = fieldAliases(entity)
35
+
36
+ const entitySrc =
37
+ `# Entity: ${entity.name}\n\n` +
38
+ `main: api: entity: ${entity.name}: {\n\n` +
39
+ ` alias: field: ${fieldAliasesSrc}\n` +
40
+ formatJsonSrc(entityJSON.substring(1, entityJSON.length - 1)) +
41
+ '\n\n}\n'
42
+
43
+ entityFiles.push({ name: entityFile, src: entitySrc })
44
+
45
+ barrel.push(`@"${Path.basename(entityFile)}"`)
46
+ }))
47
+
48
+ const indexFile = (null == opts.outprefix ? '' : opts.outprefix) + 'api-entity-index.jsonic'
49
+
50
+ return function apiEntityBuilder() {
51
+ Folder({ name: 'api' }, () => {
52
+ each(entityFiles, (entityFile) => {
53
+ File({ name: entityFile.name }, () => Content(entityFile.src))
54
+ })
55
+
56
+ File({ name: indexFile }, () => Content(barrel.join('\n')))
57
+ })
58
+ }
59
+
60
+ }
61
+
62
+ function fieldAliases(entity: any) {
63
+ // HEURISTIC: id may be name_id or nameId
64
+ const fieldAliases =
65
+ each(entity.op, (op: any) =>
66
+ each(op.param))
67
+ .flat()
68
+ .reduce((a: any, p: any) =>
69
+
70
+ (entity.field[p.keys] ? null :
71
+ (p.key$.toLowerCase().includes(entity.name) ?
72
+ (a[p.key$] = 'id', a.id = p.key$) :
73
+ null)
74
+
75
+ , a), {})
76
+
77
+ const fieldAliasesSrc =
78
+ JSON.stringify(fieldAliases, null, 2)
79
+ .replace(/\n/g, '\n ')
80
+
81
+ return fieldAliasesSrc
82
+ }
83
+
84
+
85
+
86
+ export {
87
+ resolveApiEntity
88
+ }
@@ -0,0 +1,44 @@
1
+ /* Copyright (c) 2025 Voxgig, MIT License */
2
+
3
+ import Path from 'node:path'
4
+
5
+
6
+ import type {
7
+ ApiDefOptions,
8
+ ApiModel,
9
+ } from '../../types'
10
+
11
+
12
+ import {
13
+ File,
14
+ Folder,
15
+ Content
16
+ } from 'jostraca'
17
+
18
+
19
+ function resolveDef(
20
+ apimodel: ApiModel,
21
+ opts: ApiDefOptions,
22
+ ) {
23
+ const defFile =
24
+ (null == opts.outprefix ? '' : opts.outprefix) + 'api-def.jsonic'
25
+
26
+ const modelDef = { main: { def: apimodel.main.def } }
27
+ let modelDefSrc = JSON.stringify(modelDef, null, 2)
28
+
29
+ modelDefSrc =
30
+ '# API Definition\n\n' +
31
+ modelDefSrc.substring(1, modelDefSrc.length - 1).replace(/\n /g, '\n')
32
+
33
+ return function defBuilder() {
34
+ Folder({ name: 'api' }, () => {
35
+ File({ name: defFile }, () => Content(modelDefSrc))
36
+ })
37
+ }
38
+
39
+ }
40
+
41
+
42
+ export {
43
+ resolveDef
44
+ }
@@ -0,0 +1,54 @@
1
+ /* Copyright (c) 2025 Voxgig, MIT License */
2
+
3
+ import Path from 'node:path'
4
+
5
+ // import { each } from 'jostraca'
6
+
7
+
8
+ // import type {
9
+ // ApiDefOptions,
10
+ // Log,
11
+ // FsUtil,
12
+ // } from '../types'
13
+
14
+
15
+ // import {
16
+ // writeChanged
17
+ // } from '../utility'
18
+
19
+
20
+ import {
21
+ resolveApiEntity
22
+ } from './entity/apiEntity'
23
+
24
+ import {
25
+ resolveDef
26
+ } from './entity/def'
27
+
28
+ // import {
29
+ // resolveSdkEntity
30
+ // } from './entity/sdkEntity'
31
+
32
+
33
+
34
+ async function makeEntityBuilder(ctx: any) {
35
+ const { apimodel, opts } = ctx
36
+
37
+ const apiEntityBuilder = resolveApiEntity(apimodel, opts)
38
+ const defBuilder = resolveDef(apimodel, opts)
39
+ // const sdkEntityBuilder = resolveSdkEntity(apimodel, opts)
40
+
41
+ return function entityBuilder() {
42
+ apiEntityBuilder()
43
+ defBuilder()
44
+ // sdkEntityBuilder()
45
+ }
46
+ }
47
+
48
+
49
+
50
+
51
+
52
+ export {
53
+ makeEntityBuilder
54
+ }
@@ -0,0 +1,165 @@
1
+
2
+ import { size } from '@voxgig/struct'
3
+
4
+ import { each, names } from 'jostraca'
5
+
6
+
7
+ async function flowHeuristic01(ctx: any): Promise<any[]> {
8
+ let entity = ctx.model.main.api.guide.entity
9
+
10
+ const flows: any[] = []
11
+
12
+ each(entity, (entity: any) => {
13
+ flows.push(resolveBasicEntityFlow(ctx, entity))
14
+ })
15
+
16
+ return flows
17
+ }
18
+
19
+
20
+
21
+
22
+ function resolveBasicEntityFlow(ctx: any, entity: any) {
23
+ const { apimodel, model } = ctx
24
+ const apiEntity = apimodel.main.api.entity[entity.name]
25
+
26
+ const flow: any = {
27
+ name: 'Basic' + apiEntity.Name
28
+ }
29
+
30
+ const refs = [
31
+ `${apiEntity.name}01`,
32
+ `${apiEntity.name}02`,
33
+ `${apiEntity.name}03`,
34
+ ]
35
+
36
+ const idmap = refs.reduce((a: any, ref) => (a[ref] = ref.toUpperCase(), a), {})
37
+
38
+ flow.model = ({
39
+ name: flow.Name,
40
+ param: {
41
+ [`${model.NAME}_TEST_${apiEntity.NAME}_ENTID`]: idmap
42
+ },
43
+ test: { entity: { [apiEntity.Name]: {} } },
44
+ step: []
45
+ } as any)
46
+
47
+ names(flow, flow.name)
48
+
49
+
50
+ const data = flow.model.test.entity[apiEntity.Name]
51
+
52
+ refs.map((ref, i) => {
53
+ const id = idmap[ref]
54
+ const ent: any = data[id] = {}
55
+
56
+ let num = (i * size(apiEntity.field) * 10)
57
+ each(apiEntity.field, (field) => {
58
+ ent[field.name] =
59
+ 'number' === field.type ? num :
60
+ 'boolean' === field.type ? 0 === num % 2 :
61
+ 'object' === field.type ? {} :
62
+ 'array' === field.type ? [] :
63
+ 's' + (num.toString(16))
64
+ num++
65
+ })
66
+
67
+ ent.id = id
68
+ })
69
+
70
+
71
+ const steps = flow.model.step
72
+
73
+ let num = 0
74
+ let name
75
+
76
+ if (apiEntity.op.load) {
77
+ name = `load_${apiEntity.name}${num}`
78
+ steps.push({
79
+ name,
80
+ kind: 'entity',
81
+ entity: `${apiEntity.name}`,
82
+ action: 'load',
83
+ match: {
84
+ id: `\`dm$=p.${model.NAME}_TEST_${apiEntity.NAME}_ENTID.${apiEntity.name}01\``
85
+ },
86
+ valid: {
87
+ '`$OPEN`': true,
88
+ id: `\`dm$=s.${name}.match.id\``
89
+ }
90
+ })
91
+ }
92
+
93
+ if (apiEntity.op.update && apiEntity.op.load) {
94
+ num++
95
+ name = `update_${apiEntity.name}${num}`
96
+ const id = idmap[`${apiEntity.name}01`]
97
+ const loadref = `load_${apiEntity.name}${num - 1}`
98
+ const reqdata = makeUpdateData(name, apiEntity, flow, id)
99
+ const valid = makeUpdateValid(name, apiEntity, flow, id, reqdata)
100
+ steps.push({
101
+ name,
102
+ ref: loadref,
103
+ action: 'update',
104
+ reqdata,
105
+ valid: {
106
+ '`$OPEN`': true,
107
+ id: `\`dm$=s.${loadref}.match.id\``,
108
+ ...valid
109
+ }
110
+ })
111
+
112
+ num++
113
+ name = `load_${apiEntity.name}${num}`
114
+
115
+ steps.push({
116
+ name,
117
+ kind: 'entity',
118
+ entity: `${apiEntity.name}`,
119
+ action: 'load',
120
+ match: {
121
+ id: `\`dm$=p.${model.NAME}_TEST_${apiEntity.NAME}_ENTID.${apiEntity.name}01\``
122
+ },
123
+ valid: {
124
+ '`$OPEN`': true,
125
+ id: `\`dm$=s.${loadref}.match.id\``,
126
+ ...valid
127
+ }
128
+ })
129
+
130
+ }
131
+
132
+
133
+ return flow
134
+ }
135
+
136
+
137
+ function makeUpdateData(name: string, apiEntity: any, flow: any, id: string) {
138
+ const ud: any = {}
139
+ const data = flow.model.test.entity[apiEntity.Name]
140
+
141
+ const dataFields = each(apiEntity.field).filter(f => 'id' !== f.name && !f.name.includes('_id'))
142
+ const stringFields = each(dataFields).filter(f => 'string' === f.type)
143
+
144
+ if (0 < size(stringFields)) {
145
+ const f = stringFields[0]
146
+ ud[f.name] = data[id][f.name] + '-`$WHEN`'
147
+ }
148
+
149
+ return ud
150
+ }
151
+
152
+
153
+ function makeUpdateValid(name: string, apiEntity: any, flow: any, id: string, reqdata: any) {
154
+ const vd: any = {}
155
+
156
+ each(reqdata, (n) => {
157
+ vd[n.key$] = `\`dm$=s.${name}.reqdata.${n.key$}\``
158
+ })
159
+
160
+ return vd
161
+ }
162
+
163
+ export {
164
+ flowHeuristic01
165
+ }
@@ -0,0 +1,45 @@
1
+
2
+
3
+ import { each, snakify, names } from 'jostraca'
4
+
5
+
6
+
7
+ async function flowHeuristic01(ctx: any): Promise<any[]> {
8
+ let entity = ctx.model.main.api.guide.entity
9
+
10
+ const flows: any[] = []
11
+
12
+ each(entity, (entity: any) => {
13
+ flows.push(resolveBasicEntityFlow(ctx, entity))
14
+ })
15
+
16
+ return flows
17
+ }
18
+
19
+
20
+
21
+
22
+ function resolveBasicEntityFlow(ctx: any, entity: any) {
23
+ const apiEntity = ctx.apimodel.main.api.entity[entity.name]
24
+
25
+ const flow: any = {
26
+ name: 'Basic' + apiEntity.Name
27
+ }
28
+
29
+ flow.model = ({
30
+ name: flow.Name,
31
+ param: {},
32
+ test: { entity: { [apiEntity.Name]: {} } },
33
+ step: []
34
+ } as any)
35
+
36
+ names(flow, flow.name)
37
+
38
+ return flow
39
+ }
40
+
41
+
42
+
43
+ export {
44
+ flowHeuristic01
45
+ }
@@ -0,0 +1,60 @@
1
+
2
+ import Path from 'node:path'
3
+
4
+ import { File, Content, Folder, each } from 'jostraca'
5
+
6
+ import {
7
+ formatJsonSrc,
8
+ } from '../utility'
9
+
10
+
11
+ import { flowHeuristic01 } from './flow/flowHeuristic01'
12
+
13
+
14
+ async function makeFlowBuilder(ctx: any) {
15
+ let flows: any[] = []
16
+
17
+ if ('heuristic01' === ctx.opts.strategy) {
18
+ flows = await flowHeuristic01(ctx)
19
+ }
20
+ else {
21
+ throw new Error('Unknown guide strategy: ' + ctx.opts.strategy)
22
+ }
23
+
24
+ return function flowBuilder() {
25
+
26
+ Folder({ name: 'flow' }, () => {
27
+ const barrel = [
28
+ '# Flows\n'
29
+ ]
30
+
31
+ each(flows, (flow: any) => {
32
+ let flowfile =
33
+ Path.join(ctx.opts.folder, 'flow',
34
+ (null == ctx.opts.outprefix ? '' : ctx.opts.outprefix) +
35
+ flow.Name + 'Flow.jsonic')
36
+
37
+ let flowModelSrc = formatJsonSrc(JSON.stringify(flow.model, null, 2))
38
+
39
+ let flowsrc = `# ${flow.Name}Flow
40
+
41
+ main: sdk: flow: ${flow.Name}Flow:
42
+ ` + flowModelSrc
43
+
44
+ barrel.push(`@"${Path.basename(flowfile)}"`)
45
+
46
+ File({ name: Path.basename(flowfile) }, () => Content(flowsrc))
47
+ })
48
+
49
+ File({
50
+ name: (null == ctx.opts.outprefix ? '' : ctx.opts.outprefix) + 'flow-index.jsonic'
51
+ }, () => Content(barrel.join('\n')))
52
+ })
53
+ }
54
+ }
55
+
56
+
57
+
58
+ export {
59
+ makeFlowBuilder
60
+ }
@@ -0,0 +1,225 @@
1
+
2
+
3
+ import { each, snakify, names } from 'jostraca'
4
+
5
+
6
+
7
+ async function heuristic01(ctx: any): Promise<Record<string, any>> {
8
+ let guide = ctx.model.main.api.guide
9
+
10
+
11
+ const entityDescs = resolveEntityDescs(ctx)
12
+
13
+ // console.log('entityDescs')
14
+ // console.dir(entityDescs, { depth: null })
15
+
16
+ guide = {
17
+ control: guide.control,
18
+ entity: entityDescs,
19
+ }
20
+
21
+ return guide
22
+ }
23
+
24
+
25
+ const METHOD_IDOP: Record<string, string> = {
26
+ get: 'load',
27
+ post: 'create',
28
+ put: 'update',
29
+ patch: 'update',
30
+ delete: 'remove',
31
+ }
32
+
33
+
34
+ function resolveEntityDescs(ctx: any) {
35
+ const entityDescs: Record<string, any> = {}
36
+ const paths = ctx.def.paths
37
+
38
+ each(paths, (pathdef, pathname) => {
39
+ // look for rightmmost /entname/{entid}
40
+ const m = pathname.match(/\/([a-zA-Z0-1_-]+)\/\{([a-zA-Z0-1_-]+)\}$/)
41
+ if (m) {
42
+ let origentname = snakify(m[1])
43
+ let entname = depluralize(origentname)
44
+
45
+ let entdesc = (entityDescs[entname] = entityDescs[entname] || { name: entname })
46
+ entdesc.plural = origentname
47
+
48
+ names(entdesc, entname)
49
+
50
+ entdesc.alias = entdesc.alias || {}
51
+
52
+ if ('id' != m[2]) {
53
+ entdesc.alias.id = m[2]
54
+ entdesc.alias[m[2]] = 'id'
55
+ }
56
+
57
+ entdesc.path = (entdesc.path || {})
58
+
59
+ const op: Record<string, any> = {}
60
+ entdesc.path[pathname] = { op }
61
+
62
+ each(pathdef, (mdef, method) => {
63
+ const opname = METHOD_IDOP[method]
64
+ if (null == opname) return;
65
+
66
+ const transform: Record<string, any> = {
67
+ // reqform: '`reqdata`',
68
+ // resform: '`body`',
69
+ }
70
+
71
+ const resokdef = mdef.responses[200] || mdef.responses[201]
72
+ const resbody = resokdef?.content?.['application/json']?.schema
73
+ if (resbody) {
74
+ if (resbody[origentname]) {
75
+ // TODO: use quotes when @voxgig/struct updated to support them
76
+ // transform.resform = '`body."'+origentname+'"`'
77
+ transform.resform = '`body.' + origentname + '`'
78
+ }
79
+ else if (resbody[entname]) {
80
+ transform.resform = '`body.' + entname + '`'
81
+ }
82
+ }
83
+
84
+ const reqdef = mdef.requestBody?.content?.['application/json']?.schema?.properties
85
+ if (reqdef) {
86
+ if (reqdef[origentname]) {
87
+ transform.reqform = { [origentname]: '`reqdata`' }
88
+ }
89
+ else if (reqdef[entname]) {
90
+ transform.reqform = { [entname]: '`reqdata`' }
91
+ }
92
+
93
+ }
94
+
95
+ op[opname] = {
96
+ // TODO: in actual guide, remove "standard" method ops since redundant
97
+ method,
98
+ }
99
+
100
+ if (0 < Object.entries(transform).length) {
101
+ op[opname].transform = transform
102
+ }
103
+ })
104
+ }
105
+ })
106
+
107
+
108
+ each(paths, (pathdef, pathname) => {
109
+ // look for rightmmost /entname/{entid}
110
+ const m = pathname.match(/\/([a-zA-Z0-1_-]+)$/)
111
+ if (m) {
112
+ let origentname = snakify(m[1])
113
+ let entname = depluralize(origentname)
114
+
115
+ let entdesc = entityDescs[entname]
116
+
117
+ if (entdesc) {
118
+ entdesc.path = (entdesc.path || {})
119
+
120
+ if (pathdef.get) {
121
+ const op: Record<string, any> = { list: { method: 'get' } }
122
+ entdesc.path[pathname] = { op }
123
+
124
+ const transform: Record<string, any> = {}
125
+ const mdef = pathdef.get
126
+ const resokdef = mdef.responses[200] || mdef.responses[201]
127
+ const resbody = resokdef?.content?.['application/json']?.schema
128
+ if (resbody) {
129
+ if (resbody[origentname]) {
130
+ // TODO: use quotes when @voxgig/struct updated to support them
131
+ // transform.resform = '`body."'+origentname+'"`'
132
+ transform.resform = '`body.' + origentname + '`'
133
+ }
134
+ else if (resbody[entname]) {
135
+ transform.resform = '`body.' + entname + '`'
136
+ }
137
+ }
138
+
139
+ if (0 < Object.entries(transform).length) {
140
+ op.transform = transform
141
+ }
142
+ }
143
+ }
144
+ }
145
+ })
146
+
147
+ return entityDescs
148
+ }
149
+
150
+
151
+ function depluralize(word: string): string {
152
+ if (!word || word.length === 0) {
153
+ return word
154
+ }
155
+
156
+ // Common irregular plurals
157
+ const irregulars: Record<string, string> = {
158
+ 'children': 'child',
159
+ 'men': 'man',
160
+ 'women': 'woman',
161
+ 'teeth': 'tooth',
162
+ 'feet': 'foot',
163
+ 'geese': 'goose',
164
+ 'mice': 'mouse',
165
+ 'people': 'person',
166
+ 'data': 'datum',
167
+ 'criteria': 'criterion',
168
+ 'phenomena': 'phenomenon',
169
+ 'indices': 'index',
170
+ 'matrices': 'matrix',
171
+ 'vertices': 'vertex',
172
+ 'analyses': 'analysis',
173
+ 'axes': 'axis',
174
+ 'crises': 'crisis',
175
+ 'diagnoses': 'diagnosis',
176
+ 'oases': 'oasis',
177
+ 'theses': 'thesis',
178
+ 'appendices': 'appendix'
179
+ }
180
+
181
+ if (irregulars[word]) {
182
+ return irregulars[word]
183
+ }
184
+
185
+ // Rules for regular plurals (applied in order)
186
+
187
+ // -ies -> -y (cities -> city)
188
+ if (word.endsWith('ies') && word.length > 3) {
189
+ return word.slice(0, -3) + 'y'
190
+ }
191
+
192
+ // -ves -> -f or -fe (wolves -> wolf, knives -> knife)
193
+ if (word.endsWith('ves')) {
194
+ const stem = word.slice(0, -3)
195
+ // Check if it should be -fe (like knife, wife, life)
196
+ if (['kni', 'wi', 'li'].includes(stem)) {
197
+ return stem + 'fe'
198
+ }
199
+ return stem + 'f'
200
+ }
201
+
202
+ // -oes -> -o (potatoes -> potato)
203
+ if (word.endsWith('oes')) {
204
+ return word.slice(0, -2)
205
+ }
206
+
207
+ // -ses, -xes, -zes, -shes, -ches -> remove -es (boxes -> box)
208
+ if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') ||
209
+ word.endsWith('shes') || word.endsWith('ches')) {
210
+ return word.slice(0, -2)
211
+ }
212
+
213
+ // -s -> remove -s (cats -> cat)
214
+ if (word.endsWith('s') && !word.endsWith('ss')) {
215
+ return word.slice(0, -1)
216
+ }
217
+
218
+ // If none of the rules apply, return as is
219
+ return word
220
+ }
221
+
222
+
223
+ export {
224
+ heuristic01
225
+ }