lula2 0.5.0 → 0.5.1-nightly.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.
Files changed (32) hide show
  1. package/README.md +8 -0
  2. package/dist/_app/immutable/assets/{0.Dfpe5goI.css → 0.CJjXKESY.css} +1 -1
  3. package/dist/_app/immutable/chunks/{BCoAVBju.js → BdFxWaVJ.js} +1 -1
  4. package/dist/_app/immutable/chunks/Bvop-7hR.js +2 -0
  5. package/dist/_app/immutable/chunks/{RxRVmDPY.js → Bwv82F42.js} +1 -1
  6. package/dist/_app/immutable/chunks/{BN4ish10.js → CPEw6sZY.js} +1 -1
  7. package/dist/_app/immutable/chunks/{BtwnwKFn.js → CXdZVXJf.js} +1 -1
  8. package/dist/_app/immutable/chunks/{CvviX0Gc.js → DJT-CwHK.js} +2 -2
  9. package/dist/_app/immutable/chunks/GToHgjp8.js +2 -0
  10. package/dist/_app/immutable/chunks/{DBN1r830.js → R1gz3SOr.js} +1 -1
  11. package/dist/_app/immutable/chunks/{D6NghQtU.js → zYrdvxnm.js} +1 -1
  12. package/dist/_app/immutable/entry/{app.DzkPo2gz.js → app.BSaCFUch.js} +2 -2
  13. package/dist/_app/immutable/entry/start.BEhRFQd5.js +1 -0
  14. package/dist/_app/immutable/nodes/{0.BcLFbXUF.js → 0.CVjgNaoX.js} +1 -1
  15. package/dist/_app/immutable/nodes/{1.8i0FdqjI.js → 1.DuZUhMZB.js} +1 -1
  16. package/dist/_app/immutable/nodes/{2.jit_WwBQ.js → 2.B5vQqMFa.js} +1 -1
  17. package/dist/_app/immutable/nodes/{3.CfrrpHWE.js → 3.B4oUQOz5.js} +1 -1
  18. package/dist/_app/immutable/nodes/{4.BZ-_Jk1v.js → 4.BE7_moKh.js} +1 -1
  19. package/dist/_app/version.json +1 -1
  20. package/dist/cli/commands/crawl.js +53 -1
  21. package/dist/cli/commands/ui.js +356 -308
  22. package/dist/cli/server/index.js +356 -308
  23. package/dist/cli/server/server.js +356 -308
  24. package/dist/cli/server/spreadsheetRoutes.js +343 -295
  25. package/dist/cli/server/websocketServer.js +356 -308
  26. package/dist/index.html +10 -10
  27. package/dist/index.js +408 -309
  28. package/package.json +22 -23
  29. package/src/lib/websocket.ts +106 -76
  30. package/dist/_app/immutable/chunks/B3DV5AB9.js +0 -2
  31. package/dist/_app/immutable/chunks/BIY1u1I9.js +0 -2
  32. package/dist/_app/immutable/entry/start.BUtu9VJ8.js +0 -1
package/dist/index.js CHANGED
@@ -2845,8 +2845,20 @@ var init_serverState = __esm({
2845
2845
  // cli/server/spreadsheetRoutes.ts
2846
2846
  var spreadsheetRoutes_exports = {};
2847
2847
  __export(spreadsheetRoutes_exports, {
2848
+ applyNamingConvention: () => applyNamingConvention,
2849
+ buildFieldSchema: () => buildFieldSchema,
2850
+ createOutputStructure: () => createOutputStructure,
2848
2851
  default: () => spreadsheetRoutes_default,
2849
- scanControlSets: () => scanControlSets
2852
+ detectValueType: () => detectValueType,
2853
+ extractFamilyFromControlId: () => extractFamilyFromControlId,
2854
+ parseCSV: () => parseCSV,
2855
+ parseUploadedFile: () => parseUploadedFile,
2856
+ processImportParameters: () => processImportParameters,
2857
+ processSpreadsheetData: () => processSpreadsheetData,
2858
+ scanControlSets: () => scanControlSets,
2859
+ toCamelCase: () => toCamelCase,
2860
+ toKebabCase: () => toKebabCase,
2861
+ toSnakeCase: () => toSnakeCase
2850
2862
  });
2851
2863
  import crypto from "crypto";
2852
2864
  import { parse as parseCSVSync } from "csv-parse/sync";
@@ -2895,6 +2907,334 @@ async function scanControlSets() {
2895
2907
  }).filter((cs) => cs !== null);
2896
2908
  return { controlSets };
2897
2909
  }
2910
+ function processImportParameters(reqBody) {
2911
+ const {
2912
+ controlIdField = "Control ID",
2913
+ startRow = "1",
2914
+ controlSetName = "Imported Control Set",
2915
+ controlSetDescription = "Imported from spreadsheet"
2916
+ } = reqBody;
2917
+ let justificationFields = [];
2918
+ if (reqBody.justificationFields) {
2919
+ try {
2920
+ justificationFields = JSON.parse(reqBody.justificationFields);
2921
+ debug("Justification fields received:", justificationFields);
2922
+ } catch (e) {
2923
+ console.error("Failed to parse justification fields:", e);
2924
+ }
2925
+ }
2926
+ let frontendFieldSchema = null;
2927
+ if (reqBody.fieldSchema) {
2928
+ try {
2929
+ frontendFieldSchema = JSON.parse(reqBody.fieldSchema);
2930
+ } catch (e) {
2931
+ console.error("Failed to parse fieldSchema:", e);
2932
+ }
2933
+ }
2934
+ debug("Import parameters received:", {
2935
+ controlIdField,
2936
+ startRow,
2937
+ controlSetName,
2938
+ controlSetDescription
2939
+ });
2940
+ return {
2941
+ controlIdField,
2942
+ startRow,
2943
+ controlSetName,
2944
+ controlSetDescription,
2945
+ justificationFields,
2946
+ namingConvention: "kebab-case",
2947
+ skipEmpty: true,
2948
+ skipEmptyRows: true,
2949
+ frontendFieldSchema
2950
+ };
2951
+ }
2952
+ async function parseUploadedFile(file) {
2953
+ const fileName = file.originalname || "";
2954
+ const isCSV = fileName.toLowerCase().endsWith(".csv");
2955
+ let rawData = [];
2956
+ if (isCSV) {
2957
+ const csvContent = file.buffer.toString("utf-8");
2958
+ rawData = parseCSV(csvContent);
2959
+ } else {
2960
+ const workbook = new ExcelJS.Workbook();
2961
+ const buffer = Buffer.from(file.buffer);
2962
+ await workbook.xlsx.load(buffer);
2963
+ const worksheet = workbook.worksheets[0];
2964
+ if (!worksheet) {
2965
+ throw new Error("No worksheet found in file");
2966
+ }
2967
+ worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
2968
+ const rowData = [];
2969
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
2970
+ rowData[colNumber - 1] = cell.value;
2971
+ });
2972
+ rawData[rowNumber - 1] = rowData;
2973
+ });
2974
+ }
2975
+ return rawData;
2976
+ }
2977
+ function processSpreadsheetData(rawData, headers, startRowIndex, params) {
2978
+ const controls = [];
2979
+ const families = /* @__PURE__ */ new Map();
2980
+ const fieldMetadata = /* @__PURE__ */ new Map();
2981
+ headers.forEach((header) => {
2982
+ if (header) {
2983
+ const cleanName = applyNamingConvention(header, params.namingConvention);
2984
+ fieldMetadata.set(cleanName, {
2985
+ originalName: header,
2986
+ cleanName,
2987
+ type: "string",
2988
+ maxLength: 0,
2989
+ hasMultipleLines: false,
2990
+ uniqueValues: /* @__PURE__ */ new Set(),
2991
+ emptyCount: 0,
2992
+ totalCount: 0,
2993
+ examples: []
2994
+ });
2995
+ }
2996
+ });
2997
+ for (let i = startRowIndex + 1; i < rawData.length; i++) {
2998
+ const row = rawData[i];
2999
+ if (!row || row.length === 0) continue;
3000
+ const control = {};
3001
+ let hasData = false;
3002
+ headers.forEach((header, index) => {
3003
+ if (header && row[index] !== void 0 && row[index] !== null) {
3004
+ const value = typeof row[index] === "string" ? row[index].trim() : row[index];
3005
+ const fieldName = applyNamingConvention(header, params.namingConvention);
3006
+ const metadata = fieldMetadata.get(fieldName);
3007
+ metadata.totalCount++;
3008
+ if (value === "" || value === null || value === void 0) {
3009
+ metadata.emptyCount++;
3010
+ if (params.skipEmpty) return;
3011
+ } else {
3012
+ const normalizedValue = typeof value === "string" ? value.trim() : value;
3013
+ if (normalizedValue !== "") {
3014
+ metadata.uniqueValues.add(normalizedValue);
3015
+ }
3016
+ const valueType = detectValueType(value);
3017
+ if (metadata.type === "string" || metadata.totalCount === 1) {
3018
+ metadata.type = valueType;
3019
+ } else if (metadata.type !== valueType) {
3020
+ metadata.type = "mixed";
3021
+ }
3022
+ if (typeof value === "string") {
3023
+ const length = value.length;
3024
+ if (length > metadata.maxLength) {
3025
+ metadata.maxLength = length;
3026
+ }
3027
+ if (value.includes("\n") || length > 100) {
3028
+ metadata.hasMultipleLines = true;
3029
+ }
3030
+ }
3031
+ if (metadata.examples.length < 3 && normalizedValue !== "") {
3032
+ metadata.examples.push(normalizedValue);
3033
+ }
3034
+ }
3035
+ control[fieldName] = value;
3036
+ hasData = true;
3037
+ }
3038
+ });
3039
+ if (hasData && (!params.skipEmptyRows || Object.keys(control).length > 0)) {
3040
+ const controlIdFieldName = applyNamingConvention(
3041
+ params.controlIdField,
3042
+ params.namingConvention
3043
+ );
3044
+ const controlId = control[controlIdFieldName];
3045
+ if (!controlId) {
3046
+ continue;
3047
+ }
3048
+ const family = extractFamilyFromControlId(controlId);
3049
+ control.family = family;
3050
+ controls.push(control);
3051
+ if (!families.has(family)) {
3052
+ families.set(family, []);
3053
+ }
3054
+ families.get(family).push(control);
3055
+ }
3056
+ }
3057
+ return { controls, families, fieldMetadata };
3058
+ }
3059
+ function buildFieldSchema(fieldMetadata, controls, params, families) {
3060
+ const fields = {};
3061
+ let displayOrder = 1;
3062
+ const controlIdFieldNameClean = applyNamingConvention(
3063
+ params.controlIdField,
3064
+ params.namingConvention
3065
+ );
3066
+ const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
3067
+ fields["family"] = {
3068
+ type: "string",
3069
+ ui_type: familyOptions.length <= 50 ? "select" : "short_text",
3070
+ is_array: false,
3071
+ max_length: 10,
3072
+ usage_count: controls.length,
3073
+ usage_percentage: 100,
3074
+ required: true,
3075
+ visible: true,
3076
+ show_in_table: true,
3077
+ editable: false,
3078
+ display_order: displayOrder++,
3079
+ category: "core",
3080
+ tab: "overview"
3081
+ };
3082
+ if (familyOptions.length <= 50) {
3083
+ fields["family"].options = familyOptions;
3084
+ }
3085
+ fieldMetadata.forEach((metadata, fieldName) => {
3086
+ if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
3087
+ return;
3088
+ }
3089
+ const frontendConfig = params.frontendFieldSchema?.find((f) => f.fieldName === fieldName);
3090
+ if (params.frontendFieldSchema && !frontendConfig) {
3091
+ return;
3092
+ }
3093
+ const usageCount = metadata.totalCount - metadata.emptyCount;
3094
+ const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
3095
+ let uiType = "short_text";
3096
+ const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
3097
+ const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
3098
+ nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
3099
+ metadata.maxLength <= 100 && // Reasonably short values only
3100
+ metadata.uniqueValues.size / nonEmptyCount <= 0.3;
3101
+ if (metadata.hasMultipleLines || metadata.maxLength > 500) {
3102
+ uiType = "textarea";
3103
+ } else if (isDropdownCandidate) {
3104
+ uiType = "select";
3105
+ } else if (metadata.type === "boolean") {
3106
+ uiType = "checkbox";
3107
+ } else if (metadata.type === "number") {
3108
+ uiType = "number";
3109
+ } else if (metadata.type === "date") {
3110
+ uiType = "date";
3111
+ } else if (metadata.maxLength <= 50) {
3112
+ uiType = "short_text";
3113
+ } else if (metadata.maxLength <= 200) {
3114
+ uiType = "medium_text";
3115
+ } else {
3116
+ uiType = "long_text";
3117
+ }
3118
+ let category = frontendConfig?.category || "custom";
3119
+ if (!frontendConfig) {
3120
+ if (fieldName.includes("status") || fieldName.includes("state")) {
3121
+ category = "compliance";
3122
+ } else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
3123
+ category = "core";
3124
+ } else if (fieldName.includes("note") || fieldName.includes("comment")) {
3125
+ category = "notes";
3126
+ }
3127
+ }
3128
+ const isControlIdField = fieldName === controlIdFieldNameClean;
3129
+ const fieldDef = {
3130
+ type: metadata.type,
3131
+ ui_type: uiType,
3132
+ is_array: false,
3133
+ max_length: metadata.maxLength,
3134
+ usage_count: usageCount,
3135
+ usage_percentage: usagePercentage,
3136
+ required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
3137
+ visible: frontendConfig?.tab !== "hidden",
3138
+ show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
3139
+ editable: isControlIdField ? false : true,
3140
+ display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
3141
+ category: isControlIdField ? "core" : category,
3142
+ tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
3143
+ };
3144
+ if (uiType === "select") {
3145
+ fieldDef.options = Array.from(metadata.uniqueValues).sort();
3146
+ }
3147
+ if (frontendConfig?.originalName || metadata.originalName) {
3148
+ fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
3149
+ }
3150
+ fields[fieldName] = fieldDef;
3151
+ });
3152
+ return {
3153
+ fields,
3154
+ total_controls: controls.length,
3155
+ analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
3156
+ };
3157
+ }
3158
+ async function createOutputStructure(processedData, fieldSchema, params) {
3159
+ const { controls, families } = processedData;
3160
+ const state = getServerState();
3161
+ const folderName = toKebabCase(params.controlSetName || "imported-controls");
3162
+ const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
3163
+ if (!existsSync3(baseDir)) {
3164
+ mkdirSync2(baseDir, { recursive: true });
3165
+ }
3166
+ const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
3167
+ const controlIdFieldNameClean = applyNamingConvention(
3168
+ params.controlIdField,
3169
+ params.namingConvention
3170
+ );
3171
+ const controlSetData = {
3172
+ name: params.controlSetName,
3173
+ description: params.controlSetDescription,
3174
+ version: "1.0.0",
3175
+ control_id_field: controlIdFieldNameClean,
3176
+ controlCount: controls.length,
3177
+ families: uniqueFamilies,
3178
+ fieldSchema
3179
+ };
3180
+ writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
3181
+ const controlsDir = join4(baseDir, "controls");
3182
+ const mappingsDir = join4(baseDir, "mappings");
3183
+ families.forEach((familyControls, family) => {
3184
+ const familyDir = join4(controlsDir, family);
3185
+ const familyMappingsDir = join4(mappingsDir, family);
3186
+ if (!existsSync3(familyDir)) {
3187
+ mkdirSync2(familyDir, { recursive: true });
3188
+ }
3189
+ if (!existsSync3(familyMappingsDir)) {
3190
+ mkdirSync2(familyMappingsDir, { recursive: true });
3191
+ }
3192
+ familyControls.forEach((control) => {
3193
+ const controlId = control[controlIdFieldNameClean];
3194
+ if (!controlId) {
3195
+ console.error("Missing control ID for control:", control);
3196
+ return;
3197
+ }
3198
+ const controlIdStr = String(controlId).slice(0, 50);
3199
+ const fileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
3200
+ const filePath = join4(familyDir, fileName);
3201
+ const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
3202
+ const mappingFilePath = join4(familyMappingsDir, mappingFileName);
3203
+ const filteredControl = {};
3204
+ const mappingData = {
3205
+ control_id: controlIdStr,
3206
+ justification: "",
3207
+ uuid: crypto.randomUUID()
3208
+ };
3209
+ const justificationContents = [];
3210
+ if (control.family !== void 0) {
3211
+ filteredControl.family = control.family;
3212
+ }
3213
+ Object.keys(control).forEach((fieldName) => {
3214
+ if (fieldName === "family") return;
3215
+ if (params.justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
3216
+ justificationContents.push(control[fieldName]);
3217
+ }
3218
+ const isInFrontendSchema = params.frontendFieldSchema?.some(
3219
+ (f) => f.fieldName === fieldName
3220
+ );
3221
+ const isInFieldsMetadata = fieldSchema.fields.hasOwnProperty(fieldName);
3222
+ if (isInFrontendSchema || isInFieldsMetadata) {
3223
+ filteredControl[fieldName] = control[fieldName];
3224
+ }
3225
+ });
3226
+ writeFileSync2(filePath, yaml4.dump(filteredControl));
3227
+ if (justificationContents.length > 0) {
3228
+ mappingData.justification = justificationContents.join("\n\n");
3229
+ }
3230
+ if (mappingData.justification && mappingData.justification.trim() !== "") {
3231
+ const mappingArray = [mappingData];
3232
+ writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
3233
+ }
3234
+ });
3235
+ });
3236
+ return { folderName };
3237
+ }
2898
3238
  function applyNamingConvention(fieldName, convention) {
2899
3239
  if (!fieldName) return fieldName;
2900
3240
  const cleanedName = fieldName.trim();
@@ -3275,53 +3615,9 @@ var init_spreadsheetRoutes = __esm({
3275
3615
  if (!req.file) {
3276
3616
  return res.status(400).json({ error: "No file uploaded" });
3277
3617
  }
3278
- const {
3279
- controlIdField = "Control ID",
3280
- startRow = "1",
3281
- controlSetName = "Imported Control Set",
3282
- controlSetDescription = "Imported from spreadsheet"
3283
- } = req.body;
3284
- let justificationFields = [];
3285
- if (req.body.justificationFields) {
3286
- try {
3287
- justificationFields = JSON.parse(req.body.justificationFields);
3288
- debug("Justification fields received:", justificationFields);
3289
- } catch (e) {
3290
- console.error("Failed to parse justification fields:", e);
3291
- }
3292
- }
3293
- debug("Import parameters received:", {
3294
- controlIdField,
3295
- startRow,
3296
- controlSetName,
3297
- controlSetDescription
3298
- });
3299
- const namingConvention = "kebab-case";
3300
- const skipEmpty = true;
3301
- const skipEmptyRows = true;
3302
- const fileName = req.file.originalname || "";
3303
- const isCSV = fileName.toLowerCase().endsWith(".csv");
3304
- let rawData = [];
3305
- if (isCSV) {
3306
- const csvContent = req.file.buffer.toString("utf-8");
3307
- rawData = parseCSV(csvContent);
3308
- } else {
3309
- const workbook = new ExcelJS.Workbook();
3310
- const buffer = Buffer.from(req.file.buffer);
3311
- await workbook.xlsx.load(buffer);
3312
- const worksheet = workbook.worksheets[0];
3313
- if (!worksheet) {
3314
- return res.status(400).json({ error: "No worksheet found in file" });
3315
- }
3316
- worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
3317
- const rowData = [];
3318
- row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
3319
- rowData[colNumber - 1] = cell.value;
3320
- });
3321
- rawData[rowNumber - 1] = rowData;
3322
- });
3323
- }
3324
- const startRowIndex = parseInt(startRow) - 1;
3618
+ const params = processImportParameters(req.body);
3619
+ const rawData = await parseUploadedFile(req.file);
3620
+ const startRowIndex = parseInt(params.startRow) - 1;
3325
3621
  if (rawData.length <= startRowIndex) {
3326
3622
  return res.status(400).json({ error: "Start row exceeds sheet data" });
3327
3623
  }
@@ -3332,269 +3628,21 @@ var init_spreadsheetRoutes = __esm({
3332
3628
  debug("Headers found:", headers);
3333
3629
  debug(
3334
3630
  "After conversion, looking for control ID field:",
3335
- applyNamingConvention(controlIdField, namingConvention)
3631
+ applyNamingConvention(params.controlIdField, params.namingConvention)
3336
3632
  );
3337
- const controls = [];
3338
- const families = /* @__PURE__ */ new Map();
3339
- const fieldMetadata = /* @__PURE__ */ new Map();
3340
- headers.forEach((header) => {
3341
- if (header) {
3342
- const cleanName = applyNamingConvention(header, namingConvention);
3343
- fieldMetadata.set(cleanName, {
3344
- originalName: header,
3345
- cleanName,
3346
- type: "string",
3347
- maxLength: 0,
3348
- hasMultipleLines: false,
3349
- uniqueValues: /* @__PURE__ */ new Set(),
3350
- emptyCount: 0,
3351
- totalCount: 0,
3352
- examples: []
3353
- });
3354
- }
3355
- });
3356
- for (let i = startRowIndex + 1; i < rawData.length; i++) {
3357
- const row = rawData[i];
3358
- if (!row || row.length === 0) continue;
3359
- const control = {};
3360
- let hasData = false;
3361
- headers.forEach((header, index) => {
3362
- if (header && row[index] !== void 0 && row[index] !== null) {
3363
- const value = typeof row[index] === "string" ? row[index].trim() : row[index];
3364
- const fieldName = applyNamingConvention(header, namingConvention);
3365
- const metadata = fieldMetadata.get(fieldName);
3366
- metadata.totalCount++;
3367
- if (value === "" || value === null || value === void 0) {
3368
- metadata.emptyCount++;
3369
- if (skipEmpty) return;
3370
- } else {
3371
- const normalizedValue = typeof value === "string" ? value.trim() : value;
3372
- if (normalizedValue !== "") {
3373
- metadata.uniqueValues.add(normalizedValue);
3374
- }
3375
- const valueType = detectValueType(value);
3376
- if (metadata.type === "string" || metadata.totalCount === 1) {
3377
- metadata.type = valueType;
3378
- } else if (metadata.type !== valueType) {
3379
- metadata.type = "mixed";
3380
- }
3381
- if (typeof value === "string") {
3382
- const length = value.length;
3383
- if (length > metadata.maxLength) {
3384
- metadata.maxLength = length;
3385
- }
3386
- if (value.includes("\n") || length > 100) {
3387
- metadata.hasMultipleLines = true;
3388
- }
3389
- }
3390
- if (metadata.examples.length < 3 && normalizedValue !== "") {
3391
- metadata.examples.push(normalizedValue);
3392
- }
3393
- }
3394
- control[fieldName] = value;
3395
- hasData = true;
3396
- }
3397
- });
3398
- if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
3399
- const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
3400
- const controlId = control[controlIdFieldName];
3401
- if (!controlId) {
3402
- continue;
3403
- }
3404
- const family = extractFamilyFromControlId(controlId);
3405
- control.family = family;
3406
- controls.push(control);
3407
- if (!families.has(family)) {
3408
- families.set(family, []);
3409
- }
3410
- families.get(family).push(control);
3411
- }
3412
- }
3413
- const state = getServerState();
3414
- const folderName = toKebabCase(controlSetName || "imported-controls");
3415
- const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
3416
- if (!existsSync3(baseDir)) {
3417
- mkdirSync2(baseDir, { recursive: true });
3418
- }
3419
- const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
3420
- let frontendFieldSchema = null;
3421
- if (req.body.fieldSchema) {
3422
- try {
3423
- frontendFieldSchema = JSON.parse(req.body.fieldSchema);
3424
- } catch {
3425
- }
3426
- }
3427
- const fields = {};
3428
- let displayOrder = 1;
3429
- const controlIdFieldNameClean = applyNamingConvention(controlIdField, namingConvention);
3430
- const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
3431
- fields["family"] = {
3432
- type: "string",
3433
- ui_type: familyOptions.length <= 50 ? "select" : "short_text",
3434
- // Make select if reasonable number of families
3435
- is_array: false,
3436
- max_length: 10,
3437
- usage_count: controls.length,
3438
- usage_percentage: 100,
3439
- required: true,
3440
- visible: true,
3441
- show_in_table: true,
3442
- editable: false,
3443
- display_order: displayOrder++,
3444
- category: "core",
3445
- tab: "overview"
3446
- };
3447
- if (familyOptions.length <= 50) {
3448
- fields["family"].options = familyOptions;
3449
- }
3450
- fieldMetadata.forEach((metadata, fieldName) => {
3451
- if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
3452
- return;
3453
- }
3454
- const frontendConfig = frontendFieldSchema?.find((f) => f.fieldName === fieldName);
3455
- if (frontendFieldSchema && !frontendConfig) {
3456
- return;
3457
- }
3458
- const usageCount = metadata.totalCount - metadata.emptyCount;
3459
- const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
3460
- let uiType = "short_text";
3461
- const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
3462
- const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
3463
- nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
3464
- metadata.maxLength <= 100 && // Reasonably short values only
3465
- metadata.uniqueValues.size / nonEmptyCount <= 0.3;
3466
- if (metadata.hasMultipleLines || metadata.maxLength > 500) {
3467
- uiType = "textarea";
3468
- } else if (isDropdownCandidate) {
3469
- uiType = "select";
3470
- } else if (metadata.type === "boolean") {
3471
- uiType = "checkbox";
3472
- } else if (metadata.type === "number") {
3473
- uiType = "number";
3474
- } else if (metadata.type === "date") {
3475
- uiType = "date";
3476
- } else if (metadata.maxLength <= 50) {
3477
- uiType = "short_text";
3478
- } else if (metadata.maxLength <= 200) {
3479
- uiType = "medium_text";
3480
- } else {
3481
- uiType = "long_text";
3482
- }
3483
- let category = frontendConfig?.category || "custom";
3484
- if (!frontendConfig) {
3485
- if (fieldName.includes("status") || fieldName.includes("state")) {
3486
- category = "compliance";
3487
- } else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
3488
- category = "core";
3489
- } else if (fieldName.includes("note") || fieldName.includes("comment")) {
3490
- category = "notes";
3491
- }
3492
- }
3493
- const isControlIdField = fieldName === controlIdFieldNameClean;
3494
- const fieldDef = {
3495
- type: metadata.type,
3496
- ui_type: uiType,
3497
- is_array: false,
3498
- max_length: metadata.maxLength,
3499
- usage_count: usageCount,
3500
- usage_percentage: usagePercentage,
3501
- required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
3502
- // Control ID is always required
3503
- visible: frontendConfig?.tab !== "hidden",
3504
- show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
3505
- // Always show control ID in table
3506
- editable: isControlIdField ? false : true,
3507
- // Control ID is not editable
3508
- display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
3509
- // Control ID is always first
3510
- category: isControlIdField ? "core" : category,
3511
- // Control ID is always core
3512
- tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
3513
- // Use frontend config or default
3514
- };
3515
- if (uiType === "select") {
3516
- fieldDef.options = Array.from(metadata.uniqueValues).sort();
3517
- }
3518
- if (frontendConfig?.originalName || metadata.originalName) {
3519
- fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
3520
- }
3521
- fields[fieldName] = fieldDef;
3522
- });
3523
- const fieldSchema = {
3524
- fields,
3525
- total_controls: controls.length,
3526
- analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
3527
- };
3528
- const controlSetData = {
3529
- name: controlSetName,
3530
- description: controlSetDescription,
3531
- version: "1.0.0",
3532
- control_id_field: controlIdFieldNameClean,
3533
- // Add this to indicate which field is the control ID
3534
- controlCount: controls.length,
3535
- families: uniqueFamilies,
3536
- fieldSchema
3537
- };
3538
- writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
3539
- const controlsDir = join4(baseDir, "controls");
3540
- const mappingsDir = join4(baseDir, "mappings");
3541
- families.forEach((familyControls, family) => {
3542
- const familyDir = join4(controlsDir, family);
3543
- const familyMappingsDir = join4(mappingsDir, family);
3544
- if (!existsSync3(familyDir)) {
3545
- mkdirSync2(familyDir, { recursive: true });
3546
- }
3547
- if (!existsSync3(familyMappingsDir)) {
3548
- mkdirSync2(familyMappingsDir, { recursive: true });
3549
- }
3550
- familyControls.forEach((control) => {
3551
- const controlId = control[controlIdFieldNameClean];
3552
- if (!controlId) {
3553
- console.error("Missing control ID for control:", control);
3554
- return;
3555
- }
3556
- const controlIdStr = String(controlId).slice(0, 50);
3557
- const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
3558
- const filePath = join4(familyDir, fileName2);
3559
- const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
3560
- const mappingFilePath = join4(familyMappingsDir, mappingFileName);
3561
- const filteredControl = {};
3562
- const mappingData = {
3563
- control_id: controlIdStr,
3564
- justification: "",
3565
- uuid: crypto.randomUUID()
3566
- };
3567
- const justificationContents = [];
3568
- if (control.family !== void 0) {
3569
- filteredControl.family = control.family;
3570
- }
3571
- Object.keys(control).forEach((fieldName) => {
3572
- if (fieldName === "family") return;
3573
- if (justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
3574
- justificationContents.push(control[fieldName]);
3575
- }
3576
- const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
3577
- const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
3578
- if (isInFrontendSchema || isInFieldsMetadata) {
3579
- filteredControl[fieldName] = control[fieldName];
3580
- }
3581
- });
3582
- writeFileSync2(filePath, yaml4.dump(filteredControl));
3583
- if (justificationContents.length > 0) {
3584
- mappingData.justification = justificationContents.join("\n\n");
3585
- }
3586
- if (mappingData.justification && mappingData.justification.trim() !== "") {
3587
- const mappingArray = [mappingData];
3588
- writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
3589
- }
3590
- });
3591
- });
3633
+ const processedData = processSpreadsheetData(rawData, headers, startRowIndex, params);
3634
+ const fieldSchema = buildFieldSchema(
3635
+ processedData.fieldMetadata,
3636
+ processedData.controls,
3637
+ params,
3638
+ processedData.families
3639
+ );
3640
+ const result = await createOutputStructure(processedData, fieldSchema, params);
3592
3641
  res.json({
3593
3642
  success: true,
3594
- controlCount: controls.length,
3595
- families: Array.from(families.keys()),
3596
- outputDir: folderName
3597
- // Return just the folder name, not full path
3643
+ controlCount: processedData.controls.length,
3644
+ families: Array.from(processedData.families.keys()),
3645
+ outputDir: result.folderName
3598
3646
  });
3599
3647
  } catch (error) {
3600
3648
  console.error("Error processing spreadsheet:", error);
@@ -5580,6 +5628,10 @@ function getChangedBlocks(oldText, newText) {
5580
5628
  }
5581
5629
  return changed;
5582
5630
  }
5631
+ function containsLulaAnnotations(text) {
5632
+ const lines = text.split("\n");
5633
+ return lines.some((line) => line.includes("@lulaStart") || line.includes("@lulaEnd"));
5634
+ }
5583
5635
  function crawlCommand() {
5584
5636
  return new Command().command("crawl").description("Detect compliance-related changes between @lulaStart and @lulaEnd in PR files").addOption(
5585
5637
  new Option("--post-mode <mode>", "How to post findings").choices(["review", "comment"]).default("review")
@@ -5601,8 +5653,55 @@ Please review the changes to ensure they meet compliance standards.
5601
5653
  Lula reviewed ${files.length} files changed that affect compliance.
5602
5654
 
5603
5655
  `;
5656
+ const deletedFilesWithAnnotations = [];
5657
+ for (const file of files) {
5658
+ if (file.status === "removed") {
5659
+ try {
5660
+ const oldText = await fetchRawFileViaAPI({
5661
+ octokit,
5662
+ owner,
5663
+ repo,
5664
+ path: file.filename,
5665
+ ref: "main"
5666
+ });
5667
+ if (containsLulaAnnotations(oldText)) {
5668
+ deletedFilesWithAnnotations.push(file.filename);
5669
+ }
5670
+ } catch (err) {
5671
+ console.error(`Error checking deleted file ${file.filename}: ${err}`);
5672
+ }
5673
+ }
5674
+ }
5675
+ if (deletedFilesWithAnnotations.length > 0) {
5676
+ leavePost = true;
5677
+ commentBody += `
5678
+
5679
+ **Compliance Warning: Files with Lula annotations were deleted**
5680
+
5681
+ `;
5682
+ commentBody += `The following files contained compliance annotations (\`@lulaStart\`/\`@lulaEnd\`) and were deleted in this PR. This may affect compliance coverage:
5683
+
5684
+ `;
5685
+ for (const filename of deletedFilesWithAnnotations) {
5686
+ commentBody += `- \`${filename}\`
5687
+ `;
5688
+ }
5689
+ commentBody += `
5690
+ Please review whether:
5691
+ `;
5692
+ commentBody += `- The compliance coverage provided by these files is still needed
5693
+ `;
5694
+ commentBody += `- Alternative compliance measures have been implemented
5695
+ `;
5696
+ commentBody += `- The deletion is intentional and compliance-approved
5697
+
5698
+ `;
5699
+ commentBody += `---
5700
+
5701
+ `;
5702
+ }
5604
5703
  for (const file of files) {
5605
- if (file.status === "added") continue;
5704
+ if (file.status === "added" || file.status === "removed") continue;
5606
5705
  try {
5607
5706
  const [oldText, newText] = await Promise.all([
5608
5707
  fetchRawFileViaAPI({ octokit, owner, repo, path: file.filename, ref: "main" }),