Repository: openstreetmap/iD (JavaScript)

Issue:
When object has NSI entry, tag fix is displayed as brand fix
### URL

_No response_

### How to reproduce the issue?

When editing an object with an NSI entry, tag fix/upgrade/suggestion warnings are shown as non-standard brand warnings.
This happens even when the corresponding tags are already present and there are no other/new tags to change.
In the first case it could be useful to separate the outdated tag fix to avoid slightly confusing change to non-brand tag.
In the last case, it should be shown as i.e. 'outdated tags' as usual.

Steps to reproduce
1. Add new node
2. Add i.e. `amenity=charging_station` + `name=EasyGo` => 'non-standard brand`
3. Accept brand suggestion
4. Add `survey_date=2023` => displays tag change as 'non-standard brand' change, while it is only `outdated tag' change.

### Screenshot(s) or anything else?

https://www.openstreetmap.org/node/11001736756

Observed
With brand suggestion (when only `amenity=charging_station` + `name=EasyGo` is added)
![image](https://github.com/user-attachments/assets/af115d01-876d-4a37-9b4c-f07a38d6c784)

With only the outdated tag suggestion remaining (when brand tags are already added)
![image](https://github.com/user-attachments/assets/9981042d-9b95-41f7-82f8-44af175aa3ee)

Expected for last case (when brand tags are already added)
![image](https://github.com/user-attachments/assets/6362edcc-b6f8-4028-b22e-336ee20995da)


### Which deployed environments do you see the issue in?

Released version at openstreetmap.org/edit, Development version at ideditor.netlify.app, RapiD version at mapwith.ai/rapid

### What version numbers does this issue effect?

2.30.4, 2.31.0-dev, Rapid 2.5.0

### Which browsers are you seeing this problem on?

Chrome


Discussion: Hi, I would like to contribute to OpenStreetMap and ask if you can assign this issue to me. I want to contribute as much as I can and I am still a learner so I would appreciate it if you could help me out if I get stuck anywhere.
I can confirm this bug… There seems to be some unwanted interaction between the NSI matching and deprecated tag replacement validations. However I would not consider it as a critical bug, as the other warning is also not completely off (at least in English).
Since you confirmed this bug, can I work on this and  if I make a pr, will it considered? 
@Avan2021 As this bug is touching relatively complex parts of the code, I don't necessarily think it is a good first issue to tackle for a beginner like you. If you however find a proper solution it will of course be considered.



Fix (patch):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f065f2f0e33..e72d7627e66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,6 +59,7 @@ _Breaking developer changes, which may affect downstream projects or sites that
 * Revalidate ways that are added to or removed from relations ([#10786])
 * Preserve `crossing:markings` tag when fixing missing connection of crossing path and road ([#9586], thanks [@jtracey])
 * Add a dedicated description to fix waterway-road intersections by adding a _culvert_ ([#10778], thanks [@matkoniecz])
+* Separate tag-upgrade warnings from NSI suggestions ([#10800], thanks [@k-yle])
 #### :bug: Bugfixes
 * Prevent degenerate ways caused by deleting a corner of a triangle ([#10003], thanks [@k-yle])
 * Fix briefly disappearing data layer during background layer tile layer switching transition ([#10748])
@@ -104,6 +105,7 @@ _Breaking developer changes, which may affect downstream projects or sites that
 [#10776]: https://github.com/openstreetmap/iD/issues/10776
 [#10778]: https://github.com/openstreetmap/iD/issues/10778
 [#10798]: https://github.com/openstreetmap/iD/pull/10798
+[#10800]: https://github.com/openstreetmap/iD/pull/10800
 [#10807]: https://github.com/openstreetmap/iD/issues/10807
 [@hlfan]: https://github.com/hlfan
 [@Deeptanshu-sankhwar]: https://github.com/Deeptanshu-sankhwar
diff --git a/modules/util/util.js b/modules/util/util.js
index 0039ef152cd..68dc61d50d6 100644
--- a/modules/util/util.js
+++ b/modules/util/util.js
@@ -28,8 +28,13 @@ export function utilTotalExtent(array, graph) {
     return extent;
 }
 
-
+/**
+ * @typedef {{ type: '-' | '+'; key: string; oldVal: string; newVal: string; display: string; }} TagDiff
+ * @param {Tags} oldTags
+ * @param {Tags} newTags
+ */
 export function utilTagDiff(oldTags, newTags) {
+    /** @type {TagDiff[]} */
     var tagDiff = [];
     var keys = utilArrayUnion(Object.keys(oldTags), Object.keys(newTags)).sort();
     keys.forEach(function(k) {
diff --git a/modules/validations/outdated_tags.js b/modules/validations/outdated_tags.js
index 046bb66ee6b..f1190e1948d 100644
--- a/modules/validations/outdated_tags.js
+++ b/modules/validations/outdated_tags.js
@@ -10,6 +10,8 @@ import {  utilHashcode, utilTagDiff } from '../util';
 import { utilDisplayLabel } from '../util/utilDisplayLabel';
 import { validationIssue, validationIssueFix } from '../core/validation';
 
+/** @import { TagDiff } from '../util/util'. */
+
 
 export function validationOutdatedTags() {
   const type = 'outdated_tags';
@@ -30,7 +32,6 @@ export function validationOutdatedTags() {
     if (!preset) return [];
 
     const oldTags = Object.assign({}, entity.tags);  // shallow copy
-    let subtype = 'deprecated_tags';
 
     // Upgrade preset, if a replacement is available..
     if (preset.replacement) {
@@ -40,8 +41,6 @@ export function validationOutdatedTags() {
       preset = newPreset;
     }
 
-    const upgradeReasons = [];
-
     // Upgrade deprecated tags..
     if (_dataDeprecated) {
       const deprecatedTags = entity.deprecatedTags(_dataDeprecated);
@@ -56,10 +55,6 @@ export function validationOutdatedTags() {
       if (deprecatedTags.length) {
         deprecatedTags.forEach(tag => {
           graph = actionUpgradeTags(entity.id, tag.old, tag.replace)(graph);
-          upgradeReasons.push({
-            source: 'id-tagging-schema--deprecated',
-            data: tag
-          });
         });
         entity = graph.entity(entity.id);
       }
@@ -75,14 +70,12 @@ export function validationOutdatedTags() {
           } else if (preset.addTags[k]) {
             newTags[k] = preset.addTags[k];
           }
-          upgradeReasons.push({
-            source: 'id-tagging-schema--preset-addTags',
-            data: preset
-          });
         }
       });
     }
 
+    const deprecationDiff = utilTagDiff(oldTags, newTags);
+
     // Attempt to match a canonical record in the name-suggestion-index.
     const nsi = services.nsi;
     let waitingForNsi = false;
@@ -91,81 +84,106 @@ export function validationOutdatedTags() {
       waitingForNsi = (nsi.status() === 'loading');
       if (!waitingForNsi) {
         const loc = entity.extent(graph).center();
-        nsiResult = nsi.upgradeTags(newTags, loc);
-        if (nsiResult) {
-          newTags = nsiResult.newTags;
-          subtype = 'noncanonical_brand';
-          upgradeReasons.push({
-            source: 'name-suggestion-index',
-            data: nsiResult
-          });
-        }
+        nsiResult = nsi.upgradeTags(oldTags, loc);
       }
     }
 
+    const nsiDiff = nsiResult ? utilTagDiff(oldTags, nsiResult.newTags) : [];
+
     let issues = [];
     issues.provisional = (_waitingForDeprecated || waitingForNsi);
 
-    // determine diff
-    const tagDiff = utilTagDiff(oldTags, newTags);
-    if (!tagDiff.length) return issues;
-
-    const isOnlyAddingTags = tagDiff.every(d => d.type === '+');
-
-    let prefix = '';
-    if (nsiResult) {
-      prefix = 'noncanonical_brand.';
-    } else if (subtype === 'deprecated_tags' && isOnlyAddingTags) {
-      subtype = 'incomplete_tags';
-      prefix = 'incomplete.';
+    if (deprecationDiff.length) {
+      const isOnlyAddingTags = deprecationDiff.every(d => d.type === '+');
+      const prefix = isOnlyAddingTags ? 'incomplete.' : '';
+
+      issues.push(new validationIssue({
+        type: type,
+        subtype: isOnlyAddingTags ? 'incomplete_tags' : 'deprecated_tags',
+        severity: 'warning',
+        message: (context) => {
+          const currEntity = context.hasEntity(entity.id);
+          if (!currEntity) return '';
+
+          const feature = utilDisplayLabel(currEntity, context.graph(), /* verbose */ true);
+
+          return t.append(`issues.outdated_tags.${prefix}message`, { feature });
+        },
+        reference: selection => showReference(
+          selection,
+          t.append(`issues.outdated_tags.${prefix}reference`),
+          deprecationDiff
+        ),
+        entityIds: [entity.id],
+        hash: utilHashcode(JSON.stringify(deprecationDiff)),
+        dynamicFixes: () => {
+          let fixes = [
+            new validationIssueFix({
+              title: t.append('issues.fix.upgrade_tags.title'),
+              onClick: (context) => {
+                context.perform(graph => doUpgrade(graph, deprecationDiff), t('issues.fix.upgrade_tags.annotation'));
+              }
+            })
+          ];
+          return fixes;
+        }
+      }));
     }
 
-    // don't allow autofixing brand tags
-    let autoArgs = subtype !== 'noncanonical_brand' ? [doUpgrade, t('issues.fix.upgrade_tags.annotation')] : null;
-
-    issues.push(new validationIssue({
-      type: type,
-      subtype: subtype,
-      severity: 'warning',
-      message: showMessage,
-      reference: showReference,
-      entityIds: [entity.id],
-      hash: utilHashcode(JSON.stringify(tagDiff)),
-      dynamicFixes: () => {
-        let fixes = [
-          new validationIssueFix({
-            autoArgs: autoArgs,
-            title: t.append('issues.fix.upgrade_tags.title'),
-            onClick: (context) => {
-              context.perform(doUpgrade, t('issues.fix.upgrade_tags.annotation'));
-            }
-          })
-        ];
-
-        const item = nsiResult && nsiResult.matched;
-        if (item) {
-          fixes.push(
+    if (nsiDiff.length) {
+      const isOnlyAddingTags = nsiDiff.every(d => d.type === '+');
+
+      issues.push(new validationIssue({
+        type: type,
+        subtype: 'noncanonical_brand',
+        severity: 'warning',
+        message: (context) => {
+          const currEntity = context.hasEntity(entity.id);
+          if (!currEntity) return '';
+
+          const feature = utilDisplayLabel(currEntity, context.graph(), /* verbose */ true);
+
+          return isOnlyAddingTags
+            ? t.append('issues.outdated_tags.noncanonical_brand.message_incomplete', { feature })
+            : t.append('issues.outdated_tags.noncanonical_brand.message', { feature });
+        },
+        reference: selection => showReference(
+          selection,
+          t.append('issues.outdated_tags.noncanonical_brand.reference'),
+          nsiDiff
+        ),
+        entityIds: [entity.id],
+        hash: utilHashcode(JSON.stringify(nsiDiff)),
+        dynamicFixes: () => {
+          let fixes = [
             new validationIssueFix({
-              title: t.append('issues.fix.tag_as_not.title', { name: item.displayName }),
+              title: t.append('issues.fix.upgrade_tags.title'),
+              onClick: (context) => {
+                context.perform(graph => doUpgrade(graph, nsiDiff), t('issues.fix.upgrade_tags.annotation'));
+              }
+            }),
+            new validationIssueFix({
+              title: t.append('issues.fix.tag_as_not.title', { name: nsiResult.matched.displayName }),
               onClick: (context) => {
                 context.perform(addNotTag, t('issues.fix.tag_as_not.annotation'));
               }
             })
-          );
+          ];
+          return fixes;
         }
-        return fixes;
+      }));
+    }
 
-      }
-    }));
     return issues;
 
 
-    function doUpgrade(graph) {
+    /** @param {iD.Graph} graph @param {TagDiff[]} diff */
+    function doUpgrade(graph, diff) {
       const currEntity = graph.hasEntity(entity.id);
       if (!currEntity) return graph;
 
       let newTags = Object.assign({}, currEntity.tags);  // shallow copy
-      tagDiff.forEach(diff => {
+      diff.forEach(diff => {
         if (diff.type === '-') {
           delete newTags[diff.key];
         } else if (diff.type === '+') {
@@ -200,21 +218,7 @@ export function validationOutdatedTags() {
     }
 
 
-    function showMessage(context) {
-      const currEntity = context.hasEntity(entity.id);
-      if (!currEntity) return '';
-
-      let messageID = `issues.outdated_tags.${prefix}message`;
-      if (subtype === 'noncanonical_brand' && isOnlyAddingTags) {
-        messageID += '_incomplete';
-      }
-      return t.append(messageID, {
-        feature: utilDisplayLabel(currEntity, context.graph(), true /* verbose */)
-      });
-    }
-
-
-    function showReference(selection) {
+    function showReference(selection, reference, tagDiff) {
       let enter = selection.selectAll('.issue-reference')
         .data([0])
         .enter();
@@ -222,7 +226,7 @@ export function validationOutdatedTags() {
       enter
         .append('div')
         .attr('class', 'issue-reference')
-        .call(t.append(`issues.outdated_tags.${prefix}reference`));
+        .call(reference);
 
       enter
         .append('strong')