@f5-sales-demo/pi-resource-management 19.51.2

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,76 @@
1
+ import { DEFAULTS_METADATA } from "./defaults-metadata.generated";
2
+ import type { MinimalExportFilter } from "./manifest-export";
3
+
4
+ /**
5
+ * Raw, per-kind defaults knowledge extracted from the enriched API specs
6
+ * (the api-specs-enriched single source of truth). Field paths are
7
+ * `spec.`-prefixed, matching the enriched OpenAPI schema paths.
8
+ *
9
+ * This is the input to {@link buildFilterFromMetadata}, which transforms it
10
+ * into the spec-relative {@link MinimalExportFilter} consumed by
11
+ * {@link applyMinimalExportFilter}.
12
+ */
13
+ export interface KindDefaultsMetadata {
14
+ /** Field paths the server applies a default for (from `x-f5xc-server-default`). */
15
+ readonly serverDefaultFields: readonly string[];
16
+ /** Known default values, keyed by field path (from OpenAPI `default`). */
17
+ readonly fieldDefaults: Readonly<Record<string, unknown>>;
18
+ /** Field paths required for a minimum viable config (never stripped). */
19
+ readonly minimumConfigFields: readonly string[];
20
+ /** oneOf conflicts, keyed by field path (from `x-f5xc-conflicts-with`). */
21
+ readonly fieldConflicts: Readonly<Record<string, readonly string[]>>;
22
+ }
23
+
24
+ function stripSpecPrefix(path: string): string {
25
+ return path.startsWith("spec.") ? path.slice(5) : path;
26
+ }
27
+
28
+ /**
29
+ * Transform raw per-kind defaults knowledge into a {@link MinimalExportFilter}.
30
+ *
31
+ * Returns `undefined` when the kind has no server-default fields — callers
32
+ * treat that as "export everything" (no stripping), which is the safe default
33
+ * for kinds not yet covered by the enriched specs.
34
+ */
35
+ export function buildFilterFromMetadata(meta: KindDefaultsMetadata): MinimalExportFilter | undefined {
36
+ if (meta.serverDefaultFields.length === 0) {
37
+ return undefined;
38
+ }
39
+
40
+ const serverDefaults: Record<string, unknown> = {};
41
+ const serverDefaultFields: string[] = [];
42
+
43
+ for (const path of meta.serverDefaultFields) {
44
+ const specPath = stripSpecPrefix(path);
45
+ if (path in meta.fieldDefaults) {
46
+ serverDefaults[specPath] = meta.fieldDefaults[path];
47
+ } else {
48
+ serverDefaultFields.push(specPath);
49
+ }
50
+ }
51
+
52
+ const oneofDefaultVariants: Record<string, string> = {};
53
+ for (const [path, conflictList] of Object.entries(meta.fieldConflicts)) {
54
+ if (!meta.serverDefaultFields.includes(path)) continue;
55
+ if (conflictList.length === 0) continue;
56
+ const fieldName = stripSpecPrefix(path).split(".").pop();
57
+ if (fieldName) {
58
+ oneofDefaultVariants[fieldName] = fieldName;
59
+ }
60
+ }
61
+
62
+ const minimumConfigFields = meta.minimumConfigFields.map(stripSpecPrefix);
63
+
64
+ return { serverDefaults, serverDefaultFields, minimumConfigFields, oneofDefaultVariants };
65
+ }
66
+
67
+ /**
68
+ * Build the minimum-settings export filter for a resource kind from the
69
+ * generated defaults table. Returns `undefined` for kinds with no known
70
+ * server defaults (caller exports everything).
71
+ */
72
+ export function buildMinimalExportFilter(kind: string): MinimalExportFilter | undefined {
73
+ const meta = DEFAULTS_METADATA[kind];
74
+ if (!meta) return undefined;
75
+ return buildFilterFromMetadata(meta);
76
+ }
@@ -0,0 +1,160 @@
1
+ import type { DiffEntry, ResourceDiff } from "./types";
2
+
3
+ export function computeResourceDiff(current: Record<string, unknown>, desired: Record<string, unknown>): ResourceDiff {
4
+ const added: DiffEntry[] = [];
5
+ const removed: DiffEntry[] = [];
6
+ const changed: DiffEntry[] = [];
7
+ let unchangedCount = 0;
8
+
9
+ diffObjects(current, desired, "", added, removed, changed, { count: 0 });
10
+ unchangedCount =
11
+ countLeafKeys(current) + countLeafKeys(desired) - added.length - removed.length - changed.length * 2;
12
+ if (unchangedCount < 0) unchangedCount = 0;
13
+
14
+ return {
15
+ hasDifferences: added.length > 0 || removed.length > 0 || changed.length > 0,
16
+ added,
17
+ removed,
18
+ changed,
19
+ unchangedCount,
20
+ };
21
+ }
22
+
23
+ function diffObjects(
24
+ current: unknown,
25
+ desired: unknown,
26
+ prefix: string,
27
+ added: DiffEntry[],
28
+ removed: DiffEntry[],
29
+ changed: DiffEntry[],
30
+ _ctx: { count: number },
31
+ ): void {
32
+ if (current === desired) return;
33
+ if (current === undefined || current === null) {
34
+ if (desired !== undefined && desired !== null) {
35
+ added.push({ path: prefix || "(root)", newValue: desired });
36
+ }
37
+ return;
38
+ }
39
+ if (desired === undefined || desired === null) {
40
+ removed.push({ path: prefix || "(root)", oldValue: current });
41
+ return;
42
+ }
43
+
44
+ if (Array.isArray(current) && Array.isArray(desired)) {
45
+ diffArrays(current, desired, prefix, added, removed, changed, _ctx);
46
+ return;
47
+ }
48
+
49
+ if (
50
+ typeof current === "object" &&
51
+ typeof desired === "object" &&
52
+ !Array.isArray(current) &&
53
+ !Array.isArray(desired)
54
+ ) {
55
+ const curObj = current as Record<string, unknown>;
56
+ const desObj = desired as Record<string, unknown>;
57
+ const allKeys = new Set([...Object.keys(curObj), ...Object.keys(desObj)]);
58
+
59
+ for (const key of allKeys) {
60
+ const childPath = prefix ? `${prefix}.${key}` : key;
61
+ const curVal = curObj[key];
62
+ const desVal = desObj[key];
63
+
64
+ if (!(key in curObj)) {
65
+ added.push({ path: childPath, newValue: desVal });
66
+ } else if (!(key in desObj)) {
67
+ removed.push({ path: childPath, oldValue: curVal });
68
+ } else {
69
+ diffObjects(curVal, desVal, childPath, added, removed, changed, _ctx);
70
+ }
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (!deepEqual(current, desired)) {
76
+ changed.push({ path: prefix || "(root)", oldValue: current, newValue: desired });
77
+ }
78
+ }
79
+
80
+ function diffArrays(
81
+ current: unknown[],
82
+ desired: unknown[],
83
+ prefix: string,
84
+ added: DiffEntry[],
85
+ removed: DiffEntry[],
86
+ changed: DiffEntry[],
87
+ _ctx: { count: number },
88
+ ): void {
89
+ const maxLen = Math.max(current.length, desired.length);
90
+ for (let i = 0; i < maxLen; i++) {
91
+ const childPath = `${prefix}[${i}]`;
92
+ if (i >= current.length) {
93
+ added.push({ path: childPath, newValue: desired[i] });
94
+ } else if (i >= desired.length) {
95
+ removed.push({ path: childPath, oldValue: current[i] });
96
+ } else {
97
+ diffObjects(current[i], desired[i], childPath, added, removed, changed, _ctx);
98
+ }
99
+ }
100
+ }
101
+
102
+ function deepEqual(a: unknown, b: unknown): boolean {
103
+ if (a === b) return true;
104
+ if (a == null || b == null) return a === b;
105
+ if (typeof a !== typeof b) return false;
106
+ if (typeof a !== "object") return false;
107
+
108
+ if (Array.isArray(a) && Array.isArray(b)) {
109
+ if (a.length !== b.length) return false;
110
+ return a.every((val, i) => deepEqual(val, b[i]));
111
+ }
112
+
113
+ if (Array.isArray(a) || Array.isArray(b)) return false;
114
+
115
+ const aObj = a as Record<string, unknown>;
116
+ const bObj = b as Record<string, unknown>;
117
+ const aKeys = Object.keys(aObj);
118
+ const bKeys = Object.keys(bObj);
119
+ if (aKeys.length !== bKeys.length) return false;
120
+ return aKeys.every(key => key in bObj && deepEqual(aObj[key], bObj[key]));
121
+ }
122
+
123
+ function countLeafKeys(obj: unknown): number {
124
+ if (obj == null || typeof obj !== "object") return 1;
125
+ if (Array.isArray(obj)) return obj.reduce((sum, item) => sum + countLeafKeys(item), 0);
126
+ return Object.values(obj as Record<string, unknown>).reduce((sum: number, val) => sum + countLeafKeys(val), 0);
127
+ }
128
+
129
+ export function formatDiff(diff: ResourceDiff, kind: string, name: string): string {
130
+ if (!diff.hasDifferences) {
131
+ return `${kind}/${name}: no changes detected.`;
132
+ }
133
+
134
+ const lines: string[] = [];
135
+ lines.push(`diff ${kind}/${name}`);
136
+
137
+ for (const entry of diff.removed) {
138
+ lines.push(`- ${entry.path}: ${formatValue(entry.oldValue)}`);
139
+ }
140
+ for (const added of diff.added) {
141
+ lines.push(`+ ${added.path}: ${formatValue(added.newValue)}`);
142
+ }
143
+ for (const entry of diff.changed) {
144
+ lines.push(`~ ${entry.path}: ${formatValue(entry.oldValue)} → ${formatValue(entry.newValue)}`);
145
+ }
146
+
147
+ lines.push(` ${diff.unchangedCount} field(s) unchanged`);
148
+ return lines.join("\n");
149
+ }
150
+
151
+ function formatValue(value: unknown): string {
152
+ if (value === undefined) return "<undefined>";
153
+ if (value === null) return "null";
154
+ if (typeof value === "string") return `"${value}"`;
155
+ if (typeof value === "object") {
156
+ const json = JSON.stringify(value);
157
+ return json.length > 80 ? `${json.slice(0, 77)}...` : json;
158
+ }
159
+ return String(value);
160
+ }
@@ -0,0 +1,163 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parseAllDocuments } from "yaml";
4
+
5
+ const JSON_EXTENSIONS = new Set([".json"]);
6
+ const YAML_EXTENSIONS = new Set([".yaml", ".yml"]);
7
+ const SUPPORTED_EXTENSIONS = new Set([...JSON_EXTENSIONS, ...YAML_EXTENSIONS]);
8
+
9
+ export interface FileReadResult {
10
+ objects: Record<string, unknown>[];
11
+ sourcePath: string;
12
+ }
13
+
14
+ export async function readManifestFiles(filenames: string[], recursive: boolean): Promise<FileReadResult[]> {
15
+ const results: FileReadResult[] = [];
16
+ for (const filename of filenames) {
17
+ if (filename === "-") {
18
+ results.push(await readFromStdin());
19
+ continue;
20
+ }
21
+
22
+ const resolved = path.resolve(filename);
23
+ const stat = await fs.promises.stat(resolved).catch(() => null);
24
+ if (!stat) {
25
+ throw new ManifestFileError(`File not found: ${filename}`);
26
+ }
27
+
28
+ if (stat.isDirectory()) {
29
+ const files = await collectDirectoryFiles(resolved, recursive);
30
+ for (const file of files) {
31
+ results.push(await readSingleFile(file));
32
+ }
33
+ } else {
34
+ results.push(await readSingleFile(resolved));
35
+ }
36
+ }
37
+ return results;
38
+ }
39
+
40
+ async function readSingleFile(filePath: string): Promise<FileReadResult> {
41
+ const ext = path.extname(filePath).toLowerCase();
42
+ const content = await fs.promises.readFile(filePath, "utf-8");
43
+
44
+ if (JSON_EXTENSIONS.has(ext)) {
45
+ return { objects: parseJsonContent(content, filePath), sourcePath: filePath };
46
+ }
47
+ if (YAML_EXTENSIONS.has(ext)) {
48
+ return { objects: parseYamlContent(content, filePath), sourcePath: filePath };
49
+ }
50
+
51
+ return { objects: parseAutoDetect(content, filePath), sourcePath: filePath };
52
+ }
53
+
54
+ function parseJsonContent(content: string, sourcePath: string): Record<string, unknown>[] {
55
+ const trimmed = content.trim();
56
+ if (!trimmed) return [];
57
+ try {
58
+ const parsed = JSON.parse(trimmed);
59
+ if (Array.isArray(parsed)) {
60
+ return parsed.filter((item): item is Record<string, unknown> => item != null && typeof item === "object");
61
+ }
62
+ if (typeof parsed === "object" && parsed !== null) {
63
+ return [parsed as Record<string, unknown>];
64
+ }
65
+ throw new ManifestFileError(`Expected object or array in ${sourcePath}, got ${typeof parsed}`);
66
+ } catch (err) {
67
+ if (err instanceof ManifestFileError) throw err;
68
+ throw new ManifestFileError(`Invalid JSON in ${sourcePath}: ${(err as Error).message}`);
69
+ }
70
+ }
71
+
72
+ function parseYamlContent(content: string, sourcePath: string): Record<string, unknown>[] {
73
+ const trimmed = content.trim();
74
+ if (!trimmed) return [];
75
+ try {
76
+ const docs = parseAllDocuments(trimmed);
77
+ const objects: Record<string, unknown>[] = [];
78
+ for (const doc of docs) {
79
+ if (doc.errors.length > 0) {
80
+ const firstError = doc.errors[0];
81
+ throw new ManifestFileError(`Invalid YAML in ${sourcePath}: ${firstError.message}`);
82
+ }
83
+ const value = doc.toJSON();
84
+ if (value != null && typeof value === "object" && !Array.isArray(value)) {
85
+ objects.push(value as Record<string, unknown>);
86
+ }
87
+ }
88
+ return objects;
89
+ } catch (err) {
90
+ if (err instanceof ManifestFileError) throw err;
91
+ throw new ManifestFileError(`Invalid YAML in ${sourcePath}: ${(err as Error).message}`);
92
+ }
93
+ }
94
+
95
+ function parseAutoDetect(content: string, sourcePath: string): Record<string, unknown>[] {
96
+ const trimmed = content.trim();
97
+ if (!trimmed) return [];
98
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
99
+ return parseJsonContent(content, sourcePath);
100
+ }
101
+ return parseYamlContent(content, sourcePath);
102
+ }
103
+
104
+ async function collectDirectoryFiles(dirPath: string, recursive: boolean): Promise<string[]> {
105
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
106
+ const files: string[] = [];
107
+
108
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
109
+ const fullPath = path.join(dirPath, entry.name);
110
+ if (entry.isFile()) {
111
+ const ext = path.extname(entry.name).toLowerCase();
112
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
113
+ files.push(fullPath);
114
+ }
115
+ } else if (entry.isDirectory() && recursive) {
116
+ files.push(...(await collectDirectoryFiles(fullPath, recursive)));
117
+ }
118
+ }
119
+ return files;
120
+ }
121
+
122
+ async function readFromStdin(): Promise<FileReadResult> {
123
+ return new Promise((resolve, reject) => {
124
+ const chunks: Buffer[] = [];
125
+ let timedOut = false;
126
+
127
+ const timer = setTimeout(() => {
128
+ timedOut = true;
129
+ if (chunks.length === 0) {
130
+ reject(new ManifestFileError("No data received from stdin (timed out after 5s)."));
131
+ }
132
+ }, 5000);
133
+
134
+ process.stdin.on("data", (chunk: Buffer) => {
135
+ chunks.push(chunk);
136
+ });
137
+
138
+ process.stdin.on("end", () => {
139
+ clearTimeout(timer);
140
+ if (timedOut && chunks.length === 0) return;
141
+ const content = Buffer.concat(chunks).toString("utf-8");
142
+ try {
143
+ resolve({ objects: parseAutoDetect(content, "stdin"), sourcePath: "stdin" });
144
+ } catch (err) {
145
+ reject(err);
146
+ }
147
+ });
148
+
149
+ process.stdin.on("error", err => {
150
+ clearTimeout(timer);
151
+ reject(new ManifestFileError(`Failed to read from stdin: ${err.message}`));
152
+ });
153
+
154
+ process.stdin.resume();
155
+ });
156
+ }
157
+
158
+ export class ManifestFileError extends Error {
159
+ constructor(message: string) {
160
+ super(message);
161
+ this.name = "ManifestFileError";
162
+ }
163
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ export { parseExportArgs, parseResourceArgs } from "./arg-parser";
2
+ export type { KindDefaultsMetadata } from "./defaults-metadata";
3
+ export { buildFilterFromMetadata, buildMinimalExportFilter } from "./defaults-metadata";
4
+ export { DEFAULTS_METADATA } from "./defaults-metadata.generated";
5
+ export { computeResourceDiff, formatDiff } from "./diff-engine";
6
+ export { ManifestFileError, readManifestFiles } from "./file-reader";
7
+ export { createKindResolver, KindResolutionError } from "./kind-resolver";
8
+ export type { ExportedManifest, ManifestOutputFormat, MinimalExportFilter } from "./manifest-export";
9
+ export { applyMinimalExportFilter, formatManifestOutput, toManifest, toManifestList } from "./manifest-export";
10
+ export { ManifestParseError, parseManifests } from "./manifest-parser";
11
+ export { formatValidationErrors, validateManifest, validateManifests } from "./manifest-validator";
12
+ export {
13
+ formatMultiOperationSummary,
14
+ formatOperationResult,
15
+ formatResourceDetail,
16
+ formatResourceList,
17
+ } from "./output-formatter";
18
+ export { FetchTransport, ResourceClient } from "./resource-client";
19
+ export type {
20
+ ApiSpecDomainEntry,
21
+ ApiSpecDomainResource,
22
+ ApiSpecIndex,
23
+ ApiSpecValidationResourceEntry,
24
+ DiffEntry,
25
+ HttpTransport,
26
+ HttpTransportRequest,
27
+ HttpTransportResponse,
28
+ KindResolver,
29
+ ManifestValidationResult,
30
+ OperationResult,
31
+ ParsedExportArgs,
32
+ ParsedResourceArgs,
33
+ ResolvedKind,
34
+ ResourceClientOptions,
35
+ ResourceDiff,
36
+ ResourceError,
37
+ ResourceErrorKind,
38
+ ResourceManifest,
39
+ ValidationError,
40
+ ValidationErrorCode,
41
+ ValidationWarning,
42
+ ValidationWarningCode,
43
+ } from "./types";
@@ -0,0 +1,157 @@
1
+ import type {
2
+ ApiSpecDomainResource,
3
+ ApiSpecIndex,
4
+ ApiSpecValidationResourceEntry,
5
+ KindResolver,
6
+ ResolvedKind,
7
+ } from "./types";
8
+
9
+ export class KindResolutionError extends Error {
10
+ readonly suggestions: string[];
11
+ constructor(message: string, suggestions: string[] = []) {
12
+ super(message);
13
+ this.name = "KindResolutionError";
14
+ this.suggestions = suggestions;
15
+ }
16
+ }
17
+
18
+ export function createKindResolver(
19
+ specIndex: ApiSpecIndex,
20
+ validationData?: Readonly<Record<string, ApiSpecValidationResourceEntry>>,
21
+ ): KindResolver {
22
+ function resolveKind(kind: string): ResolvedKind {
23
+ let foundWithoutPaths = false;
24
+ for (const domain of specIndex.domains) {
25
+ for (const resource of domain.resources) {
26
+ if (resource.name === kind) {
27
+ const paths = extractPaths(resource);
28
+ if (!paths) {
29
+ foundWithoutPaths = true;
30
+ continue;
31
+ }
32
+ return {
33
+ kind,
34
+ domain: domain.domain,
35
+ resource,
36
+ paths,
37
+ validation: validationData?.[kind],
38
+ };
39
+ }
40
+ }
41
+ }
42
+
43
+ if (foundWithoutPaths) {
44
+ throw new KindResolutionError(`Resource kind "${kind}" exists but has no CRUD API paths defined.`);
45
+ }
46
+
47
+ const allKinds = getAllKnownKinds();
48
+ const suggestions = findSimilarKinds(kind, allKinds).slice(0, 5);
49
+ const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
50
+ throw new KindResolutionError(`Unknown resource kind: "${kind}".${suggestionText}`, suggestions);
51
+ }
52
+
53
+ function getAllKnownKinds(): string[] {
54
+ const kinds: string[] = [];
55
+ for (const domain of specIndex.domains) {
56
+ for (const resource of domain.resources) {
57
+ kinds.push(resource.name);
58
+ }
59
+ }
60
+ return kinds.sort();
61
+ }
62
+
63
+ function getKindsWithApiPaths(): string[] {
64
+ const kinds: string[] = [];
65
+ for (const domain of specIndex.domains) {
66
+ for (const resource of domain.resources) {
67
+ if (resource.apiPaths && resource.apiPaths.length > 0) {
68
+ kinds.push(resource.name);
69
+ }
70
+ }
71
+ }
72
+ return kinds.sort();
73
+ }
74
+
75
+ return { resolveKind, getAllKnownKinds, getKindsWithApiPaths };
76
+ }
77
+
78
+ function extractPaths(resource: ApiSpecDomainResource): ResolvedKind["paths"] | null {
79
+ if (!resource.apiPaths || resource.apiPaths.length === 0) return null;
80
+
81
+ let listPath: string | undefined;
82
+ let itemPath: string | undefined;
83
+
84
+ for (const apiPath of resource.apiPaths) {
85
+ const normalized = normalizePathPlaceholders(apiPath);
86
+ const hasNamespace = normalized.includes("{namespace}");
87
+ const hasName = normalized.includes("{name}");
88
+
89
+ if (hasNamespace && !hasName && !listPath) {
90
+ listPath = normalized;
91
+ } else if (hasNamespace && hasName && !itemPath) {
92
+ itemPath = normalized;
93
+ }
94
+ }
95
+
96
+ if (!listPath && !itemPath) {
97
+ for (const apiPath of resource.apiPaths) {
98
+ const normalized = normalizePathPlaceholders(apiPath);
99
+ if (!normalized.includes("{name}") && !listPath) {
100
+ listPath = normalized;
101
+ } else if (normalized.includes("{name}") && !itemPath) {
102
+ itemPath = normalized;
103
+ }
104
+ }
105
+ }
106
+
107
+ if (!listPath) return null;
108
+ if (!itemPath && listPath) {
109
+ itemPath = `${listPath}/{name}`;
110
+ }
111
+
112
+ return {
113
+ list: listPath,
114
+ get: itemPath!,
115
+ create: listPath,
116
+ update: itemPath!,
117
+ delete: itemPath!,
118
+ };
119
+ }
120
+
121
+ function normalizePathPlaceholders(apiPath: string): string {
122
+ return apiPath.replace(/\{metadata\.namespace\}/g, "{namespace}").replace(/\{metadata\.name\}/g, "{name}");
123
+ }
124
+
125
+ function findSimilarKinds(input: string, candidates: string[]): string[] {
126
+ const scored = candidates.map(candidate => ({
127
+ kind: candidate,
128
+ score: similarityScore(input, candidate),
129
+ }));
130
+ return scored
131
+ .filter(s => s.score > 0.3)
132
+ .sort((a, b) => b.score - a.score)
133
+ .map(s => s.kind);
134
+ }
135
+
136
+ function similarityScore(a: string, b: string): number {
137
+ const la = a.toLowerCase();
138
+ const lb = b.toLowerCase();
139
+
140
+ if (la === lb) return 1;
141
+ if (lb.startsWith(la) || la.startsWith(lb)) return 0.8;
142
+ if (lb.includes(la) || la.includes(lb)) return 0.6;
143
+
144
+ const aParts = la.split("_");
145
+ const bParts = lb.split("_");
146
+ let matchCount = 0;
147
+ for (const ap of aParts) {
148
+ if (bParts.some(bp => bp === ap || bp.startsWith(ap) || ap.startsWith(bp))) {
149
+ matchCount++;
150
+ }
151
+ }
152
+ if (aParts.length > 0 && matchCount > 0) {
153
+ return (matchCount / Math.max(aParts.length, bParts.length)) * 0.5;
154
+ }
155
+
156
+ return 0;
157
+ }