Repository: rwjblue/ember-template-lint (js)

Issue:
Add mechanism to disable rules inline within a given block.
The current implementation of `<!-- template-lint disabled=true -->` or `<!-- template-lint bare-strings=false -->` disables the rule for the entire "rest" of the template they are in. We should add a better control/configuration system so that it is possible to disable all or a single rule within a given block.

The principle behind this (i.e. how we think of it applying to the DOM tree) is that we're providing two ways of configuring the linter in a template:

1. Add an instruction as an in element comment (`<div {{! template-lint`) that applies to it and (optionally) to its descendants.
2. Add an instruction as a child-less node (`{{! template-lint`) that applies to all of its later siblings nodes and (mandatorily) to their descendants.

```hbs
<div>
  {{! template-lint disabled=true }}
  {{! Things are disabled here }}
</div>
{{! But not disabled here }}
```

```hbs
{{#foo-bar as |baz|}}
  {{! template-lint invalid-interactive=false }}
  {{! invalid-interactive is disabled here }}
{{/foo-bar}}
{{! invalid-interactive is reset to its original / global configuration here }}
```

```hbs
<div>
  <div {{! template-lint rule='invalid-interactive' disabled=true}}>
    {{! invalid-interactive *not* disabled }}
  </div>
</div>
```

```hbs
<div>
  <div {{! template-lint rule='invalid-interactive' disabled=true recursive=true}}>
    {{! invalid-interactive *is* disabled }}
  </div>
</div>
```


<details>
  <summary>Original Proposal</summary>

Off the top of my head, I would like to add something like:

``` hbs
{{#template-lint disabled=true}}
  {{! Things are disabled here }}
{{/template-lint}}

{{! But not disabled here }}
```

These `{{template-lint` component invocations would be completely removed at build time (just like the HTML comments are today). This would also allow us to slowly phase out the current usage of HTML comments, which is somewhat odd anyways. 

Here is an early mockup of what this might look like: http://rwjblue.jsbin.com/vayamom/edit?html,js,output
</details>

Pull request description:
Handlebars comment instructions
Fixes #79.

This implements a new way of providing in-template instructions for configuring the rules. Rather than using HTML comments that modify a rule for the rest of the file, we can now use handlebars comments to modify a rule in some DOM-related scope.

This also allows full inline rule configuration rather than just enabling/disabling since it's easy to embed JSON strings inside handlebars comments.

I ended up solving the recursive/non-recursive issue in a fairly blunt, but I think effective way. It's pretty simple -- if we think of instructions as having names and arguments, then the name plus where the comment appears in the DOM tells you what the scope of the instruction is, and the arguments tell you what rules it affects and how. So it's all pretty much like we discussed in #79, except you can append `-tree` to the instruction name to make it recursive:

```handlebars
<div {{! template-lint-disable "my-rule" }}>
  {{! my-rule *not* disabled }}
</div>
<div {{! template-lint-disable-tree "my-rule" }}>
  {{! my-rule *is* disabled }}
</div>
```

and similarly for `template-lint-enable` and `template-lint-configure`.

Still TODO:

- [x] Deprecate old-style HTML comment instructions
- [x] Update unit tests to use new-style comment instructions
- [x] Update README

Patch:
diff --git a/README.md b/README.md
index 5d139e1..0696540 100644
--- a/README.md
+++ b/README.md
@@ -84,14 +84,75 @@ module.exports = {
 It is also possible to disable specific rules (or all rules) in a template itself:
 
 ```hbs
-{{! disable all rules for this template }}
-<!-- template-lint disable=true -->
+<!-- disable all rules -->
+{{! template-lint-disable }}
 
-{{! disable specific rules for this template }}
-<!-- template-lint bare-strings=false -->
+<!-- disable bare-strings -->
+{{! template-lint-disable bare-strings }}
+
+<!-- disable bare-strings and triple-curlies -->
+{{! template-lint-disable bare-strings triple-curlies }}
+
+<!-- enable all rules -->
+{{! template-lint-enable }}
+
+<!-- enable bare-strings -->
+{{! template-lint-enable bare-strings }}
+
+<!-- enable bare-strings and triple-curlies -->
+{{! template-lint-enable bare-strings triple-curlies }}
+```
+
+and to configure rules in the template:
+
+```hbs
+{{! template-lint-configure bare-strings ["ZOMG THIS IS ALLOWED!!!!"] }}
+
+{{! template-lint-configure bare-strings {"whitelist": "(),.", "globalAttributes": ["title"]} }}
+```
+
+The configure instruction can only configure a single rule, and the configuration value must be valid JSON that parses into a configuration for that rule.
+
+These configuration instructions do not modify the rule for the rest of the template, but instead modify it within whatever DOM scope the comment instruction appears.
+
+An instruction will apply to all later siblings and their descendants:
+
+```hbs
+<!-- disable for <p> and <span> and their contents, but not for <div> or <hr> -->
+<div>
+  <hr>
+  {{! template-lint-disable }}
+  <p>
+    <span>Hello!</span>
+  </p>
+</div>
 ```
 
-It is not currently possible to change rule configuration in the template.
+An in-element instruction will apply to only that element:
+
+```hbs
+<!-- enable for <p>, but not for <div>, <hr> or <span> -->
+<div>
+  <hr>
+  <p {{! template-lint-enable }}>
+    <span>Hello!</span>
+  </p>
+</div>
+```
+
+An in-element instruction with the `-tree` suffix will apply to that element and all its descendants:
+
+```hbs
+<!-- configure for <p>, <span> and their contents, but not for <div> or <hr> -->
+<div>
+  <hr>
+  <p {{! template-lint-configure-tree block-indentation "tab" }}>
+    <span>Hello!</span>
+  </p>
+</div>
+```
+
+Note that enabling a rule (`{{! template-lint-enable }}`) that has been configured in-template (`{{! template-lint-configure }}`), will restore it to its default configuration rather than the modified in-template configuration for the scope of the `{{! template-lint-enable }}` instruction.
 
 ### Configuration Keys
 
@@ -174,15 +235,6 @@ but allows the following:
 {{!-- comment goes here --}}
 ```
 
-Html comments containing linting instructions such as:
-
-```hbs
-<!-- template-lint bare-strings=false -->
-```
-
-are of course allowed (and since the linter strips them during processing, they will not get compiled and rendered into the DOM regardless of this rule).
-
-
 #### triple-curlies
 
 Usage of triple curly braces to allow raw HTML to be injected into the DOM is large vector for exploits of your application (especially when the raw HTML is user controllable ). Instead of using `{{{foo}}}`, you should use appropriate helpers or computed properties that return a `SafeString` (via `Ember.String.htmlSafe` generally) and ensure that user supplied data is properly escaped.
diff --git a/lib/index.js b/lib/index.js
index 3067bd6..dbc3337 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -9,7 +9,7 @@ function Linter(_options) {
   var options = _options || {};
 
   this.options = options;
-  this.console = options.console || {};
+  this.console = options.console || console;
 
   this.loadConfig();
 }
@@ -48,6 +48,7 @@ Linter.prototype = {
       var plugin = plugins[pluginName]({
         name: pluginName,
         config: this.config.rules[pluginName],
+        console: this.console,
         log: addToResults,
         defaultSeverity: this._defaultSeverityForRule(pluginName, config.pending)
       });
diff --git a/lib/rules/base.js b/lib/rules/base.js
index 1f17176..aaf63fb 100644
--- a/lib/rules/base.js
+++ b/lib/rules/base.js
@@ -1,36 +1,71 @@
 'use strict';
 
-var assign = require('lodash').assign;
-
-function invokeVisitorEnter(visitor, args, thisContext) {
-  var func;
-
-  if (typeof visitor === 'function') {
-    func = visitor;
-  } else if (visitor && visitor.enter) {
-    func = visitor.enter;
+var _ = require('lodash');
+var assign = _.assign;
+var chalk = require('chalk');
+var calculateLocationDisplay = require('../helpers/calculate-location-display');
+
+// Constant to return from AST handlers so we know to remove/unregister them
+var REMOVE_HANDLER = {};
+
+// If we need to log an error or warning about an instruction node (e.g. syntax
+// error), we want to only log it once, rather than having every rule log it,
+// spamming the user. So the rules push any nodes with syntax errors into this
+// array so only the first one will log the error, and subsequent rules will
+// see it already in the array and not log it.
+var loggedNodes = [];
+
+var reLineEnds = /(?:\r\n?|\n)/;
+var reWhitespace = /\s+/;
+var reTree = /^(.*)-tree$/;
+var reToggleRule = /^template-lint-(enable|disable)$/;
+
+function unquote(str) {
+  if (str.length < 3) {
+    return str;
   }
 
-  return func.apply(thisContext, args);
+  // Allow single or double quotes
+  var firstLetter = str[0];
+  var lastLetter = str[str.length - 1];
+  if (firstLetter === lastLetter && ['"', '\''].indexOf(firstLetter) > -1) {
+    return str.slice(1, -1);
+  }
+  return str;
 }
 
 module.exports = function(options) {
+  var allRules = require('./index');
+  var console = options.console || console;
   var log = options.log;
   var config = options.config;
   var ruleName = options.name;
   var defaultSeverity = options.defaultSeverity;
 
+  // The `firstArg !== ruleName` check isn't strictly necessary, but allows
+  // unit tests to register test rules
+  function isRuleName(name) {
+    return allRules[name] || name === ruleName;
+  }
+
   function BasePlugin(options) {
     this.options = options;
     this.syntax = null; // set by Glimmer
 
     // split into a source array (allow windows and posix line endings)
-    this.source = this.options.rawSource.split(/(?:\r\n?|\n)/g);
+    this.source = this.options.rawSource.split(reLineEnds);
 
+    this._console = console;
     this._log = log;
     this.ruleName = ruleName;
     this.severity = defaultSeverity;
+    // To support DOM-scoped configuration instructions, we need to maintain
+    // a stack of our configurations so we can restore the previous one when
+    // the current one goes out of scope. The current one is duplicated in
+    // this.config so the rule implementations don't need to worry about the
+    // fact that there is a stack.
     this.config = this.parseConfig(config);
+    this._configStack = [this.config];
   }
 
   BasePlugin.prototype.parseConfig = function(config) {
@@ -48,61 +83,401 @@ module.exports = function(options) {
     var pluginContext = this;
     var visitors = {};
     var ruleVisitors = this.visitors();
-    var ruleVisitor;
-
+    // We use this structure to advise/unadvise on AST events. The walkers we
+    // set up read this structure every time they get an AST event and call the
+    // appropriate functions. Handlers get added to the before/after list
+    // according to whether they will be called before/after the rule's AST
+    // event handlers.
+    var astHandlers = {
+      CommentStatement: {
+        enter: { before: [], after: [] },
+        exit: { before: [], after: [] }
+      },
+      MustacheCommentStatement: {
+        enter: { before: [], after: [] },
+        exit: { before: [], after: [] }
+      },
+      ElementNode: {
+        enter: { before: [], after: [] },
+        exit: { before: [], after: [] },
+        keys: {
+          children: {
+            enter: { before: [], after: [] },
+            exit: { before: [], after: [] }
+          },
+          comments: {
+            enter: { before: [], after: [] },
+            exit: { before: [], after: [] }
+          }
+        }
+      }
+    };
+    // Keep a stack of ancestor elements so that when we encounter a comment
+    // that is the child of an element (i.e. not within the element's tag)
+    // we know what it's parent is so we can set up a listener for when we
+    // leave the comment's siblings.
+    var elementStack = [];
+    // We don't traverse in-element comments until after we've entered the
+    // element, but we need to apply such instructions before we enter the
+    // element. So, we have an element enter handler that processes that
+    // element's comments, which means we need to distinguish in-element
+    // comments from child-of-element comments during the traverse so we can
+    // ignore the former, but still process the latter.
+    var inElementComments = false;
+
+    //
+    // Wrap all visitors supplied by the rule, plus make sure we have entries
+    // for the visitors we use internally for instruction processing. This is
+    // done for two reasons -- one is to support instruction processing, but
+    // the other is to allow the rule's visitor handlers to have their this
+    // context set to the plugin, which isn't what HTMLBars does.
+    //
     for (var key in ruleVisitors) {
-      ruleVisitor = ruleVisitors[key];
-
-      visitors[key] = this.processVisitor(ruleVisitor);
+      visitors[key] = this._wrapVisitor(ruleVisitors[key], astHandlers[key]);
     }
 
-    var commentVisitor = visitors['CommentStatement'];
-    if (!commentVisitor) {
-      commentVisitor = visitors['CommentStatement'] = { enter: function() {} };
-    }
+    visitors.CommentStatement = visitors.CommentStatement || this._wrapVisitor(null, astHandlers.CommentStatement);
+    visitors.MustacheCommentStatement = visitors.MustacheCommentStatement || this._wrapVisitor(null, astHandlers.MustacheCommentStatement);
+    visitors.ElementNode = visitors.ElementNode || this._wrapVisitor(null, astHandlers.ElementNode);
+
+    //
+    // Set up our handlers
+    //
+
+    // Manage elementStack
+    astHandlers.ElementNode.keys.children.enter.before.push(function(elementNode) {
+      elementStack.push(elementNode);
+    });
+    astHandlers.ElementNode.keys.children.exit.after.push(function(elementNode) {
+      if (elementNode !== _.last(elementStack)) {
+        throw new Error('Element stack out of sync with AST!');
+      }
+      elementStack.pop();
+    });
+
+    // Manage inElementComments
+    astHandlers.ElementNode.keys.comments.enter.before.push(function() {
+      inElementComments = true;
+    });
+    astHandlers.ElementNode.keys.comments.exit.after.push(function() {
+      if (!inElementComments) {
+        throw new Error('Element comments state out of sync with AST!');
+      }
+      inElementComments = false;
+    });
+
+    // Handle legacy config statements
+    astHandlers.CommentStatement.enter.after.push(function(node) {
+      if (node.value.indexOf('template-lint') === -1) {
+        return;
+      }
+
+      if (loggedNodes.indexOf(node) === -1) {
+        var locationInfo = calculateLocationDisplay(pluginContext.options.moduleName, {
+          line: node.loc.start.line,
+          column: node.loc.start.column
+        });
+
+        pluginContext._console.log(chalk.yellow([
+          'HTML comment rule instructions are deprecated. ',
+          'Please switch to Handlebars comments. ',
+          '(' + locationInfo + ')\n',
+          pluginContext.sourceForNode(node)
+        ].join('')));
+        loggedNodes.push(node);
+      }
+
+      pluginContext._processLegacyConfigNode(node);
+    });
 
-    var commentVisitorEnter = commentVisitor.enter;
-    if (commentVisitorEnter) {
-      commentVisitor.enter = function(node) {
-        if (node.value.indexOf('template-lint') > -1) {
-          pluginContext._processConfigNode(node);
+    // Handle element-child config statements
+    astHandlers.MustacheCommentStatement.enter.after.push(function(commentNode) {
+      if (inElementComments) {
+        return;
+      }
+
+      var config = pluginContext._processInstructionNode(commentNode);
+      if (!config) {
+        return;
+      }
+
+      pluginContext._pushConfig(config.value);
+
+      // Pop the config once we exit the comment's siblings
+      var parentNode = _.last(elementStack);
+      astHandlers.ElementNode.keys.children.exit.before.unshift(function(elementNode) {
+        if (elementNode === parentNode) {
+          pluginContext._popConfig(config.value);
+          return REMOVE_HANDLER;
+        }
+      });
+    });
+
+    // Handle in-element config statements
+    astHandlers.ElementNode.enter.before.push(function(elementNode) {
+      elementNode.comments.forEach(function(commentNode) {
+        var config = pluginContext._processInstructionNode(commentNode);
+        if (!config) {
+          return;
         }
 
-        commentVisitorEnter.apply(this, arguments);
-      };
-    }
+        pluginContext._pushConfig(config.value);
+
+        if (config.tree) {
+          // Applies to descendants, so pop the config once we exit the
+          // comment's element
+          astHandlers.ElementNode.exit.after.unshift(function(node) {
+            if (node === elementNode) {
+              pluginContext._popConfig(config.value);
+              return REMOVE_HANDLER;
+            }
+          });
+        } else {
+          // Applies to only this element, so pop the config when we enter the
+          // element's children, re-push it when we exit the element's
+          // children, and then re-pop it when we exit the element entirely.
+          // This is so that if the rule is listening for element exit events
+          // for some reason, the configuration will be applied when they fire,
+          // like it is applied when the enter events fire (but not applied when
+          // entering/exiting or traversing the children).
+          astHandlers.ElementNode.keys.children.enter.before.unshift(function(node) {
+            if (node === elementNode) {
+              pluginContext._popConfig(config.value);
+              return REMOVE_HANDLER;
+            }
+          });
+          astHandlers.ElementNode.keys.children.exit.after.push(function(node) {
+            if (node === elementNode) {
+              pluginContext._pushConfig(config.value);
+              return REMOVE_HANDLER;
+            }
+          });
+          astHandlers.ElementNode.exit.after.unshift(function(node) {
+            if (node === elementNode) {
+              pluginContext._popConfig(config.value);
+              return REMOVE_HANDLER;
+            }
+          });
+        }
+      });
+    });
 
     return visitors;
   };
 
-  BasePlugin.prototype.processVisitor = function(ruleVisitor) {
-    var pluginContext = this;
-    var visitor = {
-      enter: function(node) {
-        if (!pluginContext.isDisabled()) {
-          invokeVisitorEnter(ruleVisitor, [node], pluginContext);
+  BasePlugin.prototype.visitors = function() { };
+
+  // Generate a visitor handler function for a specific node type/event that
+  // will call the internal handlers and the rule handler for that node
+  // type/event in the correct order.
+  BasePlugin.prototype._wrapVisitorHandler = function(ruleHandler, internalHandlers) {
+    internalHandlers = internalHandlers || {};
+    var beforeHandlers = internalHandlers.before || [];
+    var afterHandlers = internalHandlers.after || [];
+    var plugin = this;
+    var i, ret;
+
+    return function() {
+      for (i = 0; i < beforeHandlers.length; i++) {
+        ret = beforeHandlers[i].apply(plugin, arguments);
+        if (ret === REMOVE_HANDLER) {
+          beforeHandlers.splice(i, 1);
+          i -= 1;
+        }
+      }
+
+      if (ruleHandler && !plugin.isDisabled()) {
+        ruleHandler.apply(plugin, arguments);
+      }
+
+      for (i = 0; i < afterHandlers.length; i++) {
+        ret = afterHandlers[i].apply(plugin, arguments);
+        if (ret === REMOVE_HANDLER) {
+          afterHandlers.splice(i, 1);
+          i -= 1;
         }
       }
     };
+  };
+
+  // Give the rule's visitor for a given node type, and our internal
+  // astHandlers registry for that node type, generate a visitor that will
+  // call all the various event handlers in the proper order
+  BasePlugin.prototype._wrapVisitor = function(ruleVisitor, astHandlers) {
+    if (typeof ruleVisitor === 'function') {
+      ruleVisitor = { enter: ruleVisitor };
+    }
 
-    if (ruleVisitor && ruleVisitor.exit) {
-      visitor.exit = function(node) {
-        if (!pluginContext.isDisabled()) {
-          ruleVisitor.exit.apply(pluginContext, [node]);
+    ruleVisitor = ruleVisitor || {};
+    astHandlers = astHandlers || {};
+
+    var visitorKeys = ruleVisitor.keys || {};
+    var visitorChildren = visitorKeys.children || {};
+    var visitorComments = visitorKeys.comments || {};
+    var internalKeys = astHandlers.keys || {};
+    var internalChildren = internalKeys.children || {};
+    var internalComments = internalKeys.comments || {};
+
+    return {
+      enter: this._wrapVisitorHandler(ruleVisitor.enter, astHandlers.enter),
+      exit: this._wrapVisitorHandler(ruleVisitor.exit, astHandlers.exit),
+      keys: {
+        children: {
+          enter: this._wrapVisitorHandler(visitorChildren.enter, internalChildren.enter),
+          exit: this._wrapVisitorHandler(visitorChildren.exit, internalChildren.exit)
+        },
+        comments: {
+          enter: this._wrapVisitorHandler(visitorComments.enter, internalComments.enter),
+          exit: this._wrapVisitorHandler(visitorComments.exit, internalComments.exit)
         }
-      };
+      }
+    };
+  };
+
+  BasePlugin.prototype._pushConfig = function(config) {
+    this._configStack.push(config);
+    this.config = config;
+  };
+
+  BasePlugin.prototype._popConfig = function(config) {
+    if (_.last(this._configStack) !== config) {
+      throw new Error('Configuration stack out of sync with AST!');
     }
 
-    return visitor;
+    this._configStack.pop();
+    this.config = _.last(this._configStack);
   };
 
-  BasePlugin.prototype.visitors = function() { };
+  BasePlugin.prototype._processInstructionNode = function(node) {
+    var nodeValue = node.value.trim();
+    var instructionName = nodeValue.split(reWhitespace)[0];
+    var instructionArgs = nodeValue.slice(instructionName.length + 1).trim();
+    var config = { tree: false };
+    var m;
+
+    m = reTree.exec(instructionName);
+    if (m) {
+      config.tree = true;
+      instructionName = m[1];
+    }
 
-  BasePlugin.prototype._processConfigNode = function(node) {
+    m = reToggleRule.exec(instructionName);
+    if (m) {
+      // If no instructionArgs, it's just disabling everything, so apply the
+      // config
+      if (instructionArgs) {
+        // It includes an explicit list of rules, so not just disabling
+        // everything. So, let's validate the rule names, and then see if it
+        // contains us.
+        var names = instructionArgs.split(reWhitespace).map(unquote);
+
+        // Validate rule names. If one or more fail to validate, don't return,
+        // though, because if we can still find our rule name in the list, we
+        // can process the instruction just fine.
+        if (loggedNodes.indexOf(node) === -1) {
+          var badNames = names.filter(function(name) {
+            return !isRuleName(name);
+          });
+          for (var i = 0; i < badNames.length; i++) {
+            this.log({
+              message: 'unrecognized rule name `' + badNames[i] + '` in ' + instructionName + ' instruction',
+              line: node.loc && node.loc.start.line,
+              column: node.loc && node.loc.start.column,
+              source: this.sourceForNode(node),
+              rule: 'global'
+            });
+          }
+
+          if (badNames.length > 0) {
+            loggedNodes.push(node);
+          }
+        }
+
+        if (names.indexOf(ruleName) === -1) {
+          // Explicit list that doesn't include us
+          return null;
+        }
+      }
+
+      if (m[1] === 'disable') {
+        config.value = false;
+      } else {
+        // Enable means set to the default config, i.e. the bottom of our
+        // config stack...unless it was originally disabled, and then just
+        // enable it.
+        if (this._configStack[0] === false) {
+          config.value = true;
+        } else {
+          config.value = this._configStack[0];
+        }
+      }
+
+      return config;
+    } else if (instructionName === 'template-lint-configure') {
+      // This instruction must list exactly one rule
+      var firstArg = instructionArgs.split(reWhitespace)[0];
+      var jsonString = instructionArgs.slice(firstArg.length).trim();
+
+      firstArg = unquote(firstArg);
+
+      // Make sure the first argument is a valid rule name as a heuristic to
+      // check for syntax errors.
+      if (!isRuleName(firstArg)) {
+        // Invalid rule
+        if (loggedNodes.indexOf(node) === -1) {
+          this.log({
+            message: 'unrecognized rule name `' + firstArg + '` in template-lint-configure instruction',
+            line: node.loc && node.loc.start.line,
+            column: node.loc && node.loc.start.column,
+            source: this.sourceForNode(node),
+            rule: 'global'
+          });
+          loggedNodes.push(node);
+        }
+        return;
+      }
+
+      if (firstArg !== ruleName) {
+        // Valid rule, but not us, so not relevant
+        return null;
+      }
+
+      try {
+        config.value = JSON.parse(jsonString);
+        return config;
+      } catch (e) {
+        if (loggedNodes.indexOf(node) === -1) {
+          this.log({
+            message: 'malformed template-lint-configure instruction: `' + jsonString + '` is not valid JSON',
+            line: node.loc && node.loc.start.line,
+            column: node.loc && node.loc.start.column,
+            source: this.sourceForNode(node),
+            rule: 'global'
+          });
+          loggedNodes.push(node);
+        }
+      }
+    } else if (instructionName.slice(0, 'template-lint'.length) === 'template-lint') {
+      if (loggedNodes.indexOf(node) === -1) {
+        this.log({
+          message: 'unrecognized template-lint instruction: `' + instructionName + '`',
+          line: node.loc && node.loc.start.line,
+          column: node.loc && node.loc.start.column,
+          source: this.sourceForNode(node),
+          rule: 'global'
+        });
+        loggedNodes.push(node);
+      }
+    }
+
+    return null;
+  };
+
+  BasePlugin.prototype._processLegacyConfigNode = function(node) {
     var hashParts = node.value
           .trim()
           .slice(14)
-          .split(/\s+/);
+          .split(reWhitespace);
 
     var hash = hashParts.reduce(function(memo, part) {
       var parts = part.split('=');
@@ -114,14 +489,21 @@ module.exports = function(options) {
 
     for (var key in hash) {
       var value = hash[key];
+      var i;
 
       // handle <!-- template-lint disabled=true -->
       if (key === 'disable' && value === 'true') {
+        for (i = 0; i < this._configStack.length; i++) {
+          this._configStack[i] = false;
+        }
         this.config = false;
       }
 
       // handle <!-- template-lint block-indentation=false -->
       if (key === ruleName && value === 'false') {
+        for (i = 0; i < this._configStack.length; i++) {
+          this._configStack[i] = false;
+        }
         this.config = false;
       }
     }
diff --git a/package.json b/package.json
index 185f278..630d231 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
   "dependencies": {
     "chalk": "^1.1.3",
     "exists-sync": "0.0.4",
-    "glimmer-engine": "^0.18.0",
+    "glimmer-engine": "^0.17.8",
     "lodash": "^4.11.1"
   },
   "bugs": {


Test patch:
diff --git a/test/helpers/rule-test-harness.js b/test/helpers/rule-test-harness.js
index 6ee4f0f..af13a3b 100644
--- a/test/helpers/rule-test-harness.js
+++ b/test/helpers/rule-test-harness.js
@@ -8,8 +8,8 @@ var assign = require('lodash').assign;
 module.exports = function(options) {
   var groupingMethod = options.focus ? describe.only : describe;
   groupingMethod(options.name, function() {
-    var DISABLE_ALL = '<!-- template-lint disable=true -->';
-    var DISABLE_ONE = '<!-- template-lint ' + options.name + '=false -->';
+    var DISABLE_ALL = '{{! template-lint-disable }}';
+    var DISABLE_ONE = '{{! template-lint-disable ' + options.name + ' }}';
 
     var linter, config;
 
diff --git a/test/unit/base-plugin-test.js b/test/unit/base-plugin-test.js
index 59f3eec..801ca31 100644
--- a/test/unit/base-plugin-test.js
+++ b/test/unit/base-plugin-test.js
@@ -4,89 +4,724 @@ var assert = require('power-assert');
 var _precompile = require('glimmer-engine').precompile;
 var buildPlugin = require('./../../lib/rules/base');
 
-describe('base plugin tests', function() {
-  var messages, config;
-
-  function plugin() {
-    var FakePlugin = buildPlugin({
-      log: function(result) {
-        messages.push(result.source);
-      },
-      name: 'fake',
-      config: config
-    });
-
-    FakePlugin.prototype.visitors = function() {
-      return {
-        ElementNode: function(node) {
-          this.process(node);
-        },
-
-        TextNode: function(node) {
-          if (!node.loc) { return; }
-
-          this.process(node);
-        }
-      };
-    };
-
-    FakePlugin.prototype.process = function(node) {
-      this.log({
-        message: 'Node source',
-        line: node.loc && node.loc.start.line,
-        column: node.loc && node.loc.start.column,
-        source: this.sourceForNode(node)
-      });
-    };
-
-    return FakePlugin;
-  }
-
-  function precompile(template) {
+describe('base plugin', function() {
+  function precompileTemplate(template, ast) {
     _precompile(template, {
       rawSource: template,
       moduleName: 'layout.hbs',
       plugins: {
-        ast: [
-          plugin()
-        ]
+        ast: ast
       }
     });
   }
 
-  beforeEach(function() {
-    messages = [];
-    config   = {};
+  describe('parses templates', function() {
+    var messages, config;
+
+    function plugin() {
+      var FakePlugin = buildPlugin({
+        log: function(result) {
+          messages.push(result.source);
+        },
+        name: 'fake',
+        config: config
+      });
+
+      FakePlugin.prototype.visitors = function() {
+        return {
+          ElementNode: function(node) {
+            this.process(node);
+          },
+
+          TextNode: function(node) {
+            if (!node.loc) {
+              return;
+            }
+
+            this.process(node);
+          }
+        };
+      };
+
+      FakePlugin.prototype.process = function(node) {
+        this.log({
+          message: 'Node source',
+          line: node.loc && node.loc.start.line,
+          column: node.loc && node.loc.start.column,
+          source: this.sourceForNode(node)
+        });
+      };
+
+      return FakePlugin;
+    }
+
+    function precompile(template) {
+      precompileTemplate(template, [ plugin() ]);
+    }
+
+    beforeEach(function() {
+      messages = [];
+      config = {};
+    });
+
+    function expectSource(config) {
+      var template = config.template;
+      var nodeSources = config.sources;
+
+      it('can get raw source for `' + template + '`', function() {
+        precompile(template);
+
+        assert.deepEqual(messages, nodeSources);
+      });
+    }
+
+    expectSource({
+      template: '<div>Foo</div>',
+      sources: [
+        '<div>Foo</div>',
+        'Foo'
+      ]
+    });
+
+    expectSource({
+      template: '<div>\n  <div data-foo="blerp">\n    Wheee!\n  </div>\n</div>',
+      sources: [
+        '<div>\n  <div data-foo="blerp">\n    Wheee!\n  </div>\n</div>',
+        '\n  ',
+        '<div data-foo="blerp">\n    Wheee!\n  </div>',
+        '"blerp"',
+        '\n    Wheee!\n  ',
+        '\n'
+      ]
+    });
   });
 
-  function expectSource(config) {
-    var template = config.template;
-    var nodeSources = config.sources;
+  describe('parses instructions', function() {
+    var messages, config;
 
-    it('can get raw source for `' + template + '`', function() {
-      precompile(template);
+    function plugin(name) {
+      var FakePlugin = buildPlugin({
+        log: function(result) {
+          messages.push(result.message);
+        },
+        name: name || 'fake',
+        config: 'foo'
+      });
 
-      assert.deepEqual(messages, nodeSources);
+      FakePlugin.prototype.visitors = function() {
+        return {
+          MustacheCommentStatement: function(node) {
+            this.process(node);
+          }
+        };
+      };
+
+      FakePlugin.prototype.process = function(node) {
+        config = this._processInstructionNode(node);
+      };
+
+      return FakePlugin;
+    }
+
+    function precompile(template) {
+      precompileTemplate(template, [ plugin() ]);
+    }
+
+    beforeEach(function() {
+      messages = [];
+      config = null;
+    });
+
+    function expectConfig(instruction, expectedConfig) {
+      it('can parse `' + instruction + '`', function() {
+        precompile('{{! ' + instruction + ' }}');
+        assert.deepEqual(config, expectedConfig);
+        assert.deepEqual(messages, []);
+      });
+    }
+
+    // Global enable/disable
+    expectConfig('template-lint-disable', { value: false, tree: false });
+    expectConfig('template-lint-disable-tree', { value: false, tree: true });
+    expectConfig('template-lint-enable', { value: 'foo', tree: false });
+    expectConfig('template-lint-enable-tree', { value: 'foo', tree: true });
+    expectConfig('  template-lint-enable-tree ', { value: 'foo', tree: true });
+
+    // Specific enable/disable
+    expectConfig('template-lint-disable fake', { value: false, tree: false });
+    expectConfig('template-lint-disable-tree "fake"', { value: false, tree: true });
+    expectConfig('template-lint-disable fake \'bare-strings\'', { value: false, tree: false });
+    expectConfig('template-lint-disable bare-strings fake block-indentation', { value: false, tree: false });
+    expectConfig('template-lint-disable bare-strings', null);
+    expectConfig(' template-lint-disable   fake ', { value: false, tree: false });
+    expectConfig('template-lint-disable   bare-strings    fake   block-indentation ', { value: false, tree: false });
+
+    // Configure
+    expectConfig('template-lint-configure fake { "key1": "value", "key2": { "key3": 1 } }', {
+      value: { key1: 'value', key2: { key3: 1 } },
+      tree: false
+    });
+    expectConfig('template-lint-configure-tree "fake" { "key": "value" }', {
+      value: { key: 'value' },
+      tree: true
+    });
+    expectConfig('template-lint-configure-tree \'fake\' true', {
+      value: true,
+      tree: true
+    });
+    expectConfig('template-lint-configure-tree fake false', {
+      value: false,
+      tree: true
+    });
+    expectConfig('template-lint-configure-tree bare-strings { "key": "value" }', null);
+    expectConfig('  template-lint-configure-tree    fake   { "key": "value" }', {
+      value: { key: 'value' },
+      tree: true
+    });
+
+    // Not config
+    expectConfig('this code is awesome', null);
+    expectConfig('', null);
+
+    // Errors
+    it('logs an error when it encounters an unknown rule name', function() {
+      precompile([
+        '{{! template-lint-enable notarule }}',
+        '{{! template-lint-disable fake norme meneither }}',
+        '{{! template-lint-configure nope false }}'
+      ].join('\n'));
+      assert.deepEqual(messages, [
+        'unrecognized rule name `notarule` in template-lint-enable instruction',
+        'unrecognized rule name `norme` in template-lint-disable instruction',
+        'unrecognized rule name `meneither` in template-lint-disable instruction',
+        'unrecognized rule name `nope` in template-lint-configure instruction'
+      ]);
+    });
+
+    it('logs an error when it can\'t parse a configure instruction\'s JSON', function() {
+      precompile('{{! template-lint-configure fake { not: "json" ] }}');
+      assert.deepEqual(messages, [
+        'malformed template-lint-configure instruction: `{ not: "json" ]` is not valid JSON'
+      ]);
     });
-  }
 
-  expectSource({
-    template: '<div>Foo</div>',
-    sources: [
-      '<div>Foo</div>',
-      'Foo'
-    ]
+    it('logs an error when it encounters an unrecognized instruction starting with `template-lint`', function() {
+      precompile([
+        '{{! template-lint-bloober fake }}',
+        '{{! template-lint- fake }}',
+        '{{! template-lint fake }}'
+      ].join('\n'));
+      assert.deepEqual(messages, [
+        'unrecognized template-lint instruction: `template-lint-bloober`',
+        'unrecognized template-lint instruction: `template-lint-`',
+        'unrecognized template-lint instruction: `template-lint`'
+      ]);
+    });
+
+    it('only logs syntax errors once across all rules', function() {
+      precompileTemplate('{{! template-lint-enable notarule }}{{! template-lint-disable meneither }}{{! template-lint-configure norme true }}', [
+        plugin('fake1'),
+        plugin('fake2'),
+        plugin('fake3'),
+        plugin('fake4'),
+        plugin('fake5')
+      ]);
+      assert.equal(messages.length, 3);
+    });
   });
 
-  expectSource({
-    template: '<div>\n  <div data-foo="blerp">\n    Wheee!\n  </div>\n</div>',
-    sources: [
-      '<div>\n  <div data-foo="blerp">\n    Wheee!\n  </div>\n</div>',
-      '\n  ',
-      '<div data-foo="blerp">\n    Wheee!\n  </div>',
-      '"blerp"',
-      '\n    Wheee!\n  ',
-      '\n'
-    ]
+  describe('scopes instructions', function() {
+    var messages, events;
+
+    function getId(node) {
+      if (node.attributes) {
+        for (var i = 0; i < node.attributes.length; i++) {
+          if (node.attributes[i].name === 'id') {
+            return node.attributes[i].value.chars;
+          }
+        }
+      }
+      return '';
+    }
+
+    function addEvent(event, node, plugin) {
+      events.push([ event, getId(node), plugin.config ]);
+    }
+
+    function plugin(config) {
+      if (config === undefined) {
+        config = true;
+      }
+
+      var FakePlugin = buildPlugin({
+        log: function(result) {
+          messages.push(result.source);
+        },
+        name: 'fake',
+        config: config
+      });
+
+      FakePlugin.prototype.visitors = function() {
+        var pluginContext = this;
+
+        return {
+          ElementNode: {
+            enter: function(node) {
+              addEvent('element/enter', node, pluginContext);
+            },
+            exit: function(node) {
+              addEvent('element/exit', node, pluginContext);
+            },
+            keys: {
+              children: {
+                enter: function(node) {
+                  addEvent('element/enter:children', node, pluginContext);
+                },
+                exit: function(node) {
+                  addEvent('element/exit:children', node, pluginContext);
+                }
+              }
+            }
+          },
+          MustacheCommentStatement: {
+            enter: function(node) {
+              addEvent('comment/enter', node, pluginContext);
+            },
+            exit: function(node) {
+              addEvent('comment/exit', node, pluginContext);
+            }
+          }
+        };
+      };
+
+      return FakePlugin;
+    }
+
+    function precompile(template, config) {
+      precompileTemplate(template, [ plugin(config) ]);
+    }
+
+    beforeEach(function() {
+      messages = [];
+      events = [];
+    });
+
+    function expectEvents(data) {
+      var description = data.desc;
+      var template = data.template;
+      var expectedEvents = data.events;
+      var config = data.config;
+
+      it(description, function() {
+        precompile(template, config);
+        assert.deepEqual(events, expectedEvents);
+        assert.deepEqual(messages, []);
+      });
+    }
+
+    expectEvents({
+      desc: 'handles top-level instructions',
+      template: [
+        '{{! template-lint-configure fake "foo" }}',
+        '<div id="id1"></div>'
+      ].join('\n'),
+      events: [
+        [ 'comment/enter',          '',    true  ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/enter',          'id1', 'foo' ],
+        [ 'element/enter:children', 'id1', 'foo' ],
+        [ 'element/exit:children',  'id1', 'foo' ],
+        [ 'element/exit',           'id1', 'foo' ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles element-child instructions',
+      template: [
+        '<div id="id1">',
+        '  {{! template-lint-configure fake "foo" }}',
+        '  <span id="id2"></span>',
+        '</div>',
+        '<i id="id3"></i>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'comment/enter',          '',    true  ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ],
+        [ 'element/enter',          'id3', true  ],
+        [ 'element/enter:children', 'id3', true  ],
+        [ 'element/exit:children',  'id3', true  ],
+        [ 'element/exit',           'id3', true  ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles niece/nephew instructions',
+      template: [
+        '<div id="id1">',
+        '  {{! template-lint-configure fake "foo" }}',
+        '  <span id="id2">',
+        '    {{! template-lint-configure fake "bar" }}',
+        '    <b id="id3"/>',
+        '  </span>',
+        '</div>',
+        '<i id="id4"></i>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'comment/enter',          '',    true  ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'bar' ],
+        [ 'element/enter',          'id3', 'bar' ],
+        [ 'element/enter:children', 'id3', 'bar' ],
+        [ 'element/exit:children',  'id3', 'bar' ],
+        [ 'element/exit',           'id3', 'bar' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ],
+        [ 'element/enter',          'id4', true  ],
+        [ 'element/enter:children', 'id4', true  ],
+        [ 'element/exit:children',  'id4', true  ],
+        [ 'element/exit',           'id4', true  ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles sibling instructions',
+      template: [
+        '<div id="id1">',
+        '  {{! template-lint-configure fake "foo" }}',
+        '  <span id="id2"/>',
+        '  {{! template-lint-configure fake "bar" }}',
+        '  <b id="id3"/>',
+        '</div>',
+        '<i id="id4"></i>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'comment/enter',          '',    true  ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'bar' ],
+        [ 'element/enter',          'id3', 'bar' ],
+        [ 'element/enter:children', 'id3', 'bar' ],
+        [ 'element/exit:children',  'id3', 'bar' ],
+        [ 'element/exit',           'id3', 'bar' ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ],
+        [ 'element/enter',          'id4', true  ],
+        [ 'element/enter:children', 'id4', true  ],
+        [ 'element/exit:children',  'id4', true  ],
+        [ 'element/exit',           'id4', true  ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles in-element instructions',
+      template: [
+        '<div id="id1">',
+        '  <span id="id2" {{! template-lint-configure fake "foo" }}>',
+        '    <i id="id3">',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', true  ],
+        [ 'element/enter',          'id3', true  ],
+        [ 'element/enter:children', 'id3', true  ],
+        [ 'element/enter',          'id4', true  ],
+        [ 'element/enter:children', 'id4', true  ],
+        [ 'element/exit:children',  'id4', true  ],
+        [ 'element/exit',           'id4', true  ],
+        [ 'element/exit:children',  'id3', true  ],
+        [ 'element/exit',           'id3', true  ],
+        [ 'element/exit:children',  'id2', true  ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles in-element tree instructions',
+      template: [
+        '<div id="id1">',
+        '  <span id="id2" {{! template-lint-configure-tree fake "foo" }}>',
+        '    <i id="id3">',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'element/enter',          'id3', 'foo' ],
+        [ 'element/enter:children', 'id3', 'foo' ],
+        [ 'element/enter',          'id4', 'foo' ],
+        [ 'element/enter:children', 'id4', 'foo' ],
+        [ 'element/exit:children',  'id4', 'foo' ],
+        [ 'element/exit',           'id4', 'foo' ],
+        [ 'element/exit:children',  'id3', 'foo' ],
+        [ 'element/exit',           'id3', 'foo' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles in-element instruction in descendant of in-element tree instruction',
+      template: [
+        '<div id="id1" {{! template-lint-configure-tree fake "foo" }}>',
+        '  <span id="id2">',
+        '    <i id="id3" {{! template-lint-configure fake "bar" }}>',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', 'foo' ],
+        [ 'element/enter:children', 'id1', 'foo' ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'element/enter',          'id3', 'bar' ],
+        [ 'element/enter:children', 'id3', 'foo' ],
+        [ 'element/enter',          'id4', 'foo' ],
+        [ 'element/enter:children', 'id4', 'foo' ],
+        [ 'element/exit:children',  'id4', 'foo' ],
+        [ 'element/exit',           'id4', 'foo' ],
+        [ 'element/exit:children',  'id3', 'foo' ],
+        [ 'comment/enter',          '',    'bar' ],
+        [ 'comment/exit',           '',    'bar' ],
+        [ 'element/exit',           'id3', 'bar' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/exit',           'id1', 'foo' ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles in-element tree instruction in descendant of in-element tree instruction',
+      template: [
+        '<div id="id1" {{! template-lint-configure-tree fake "foo" }}>',
+        '  <span id="id2">',
+        '    <i id="id3" {{! template-lint-configure-tree fake "bar" }}>',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', 'foo' ],
+        [ 'element/enter:children', 'id1', 'foo' ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'element/enter',          'id3', 'bar' ],
+        [ 'element/enter:children', 'id3', 'bar' ],
+        [ 'element/enter',          'id4', 'bar' ],
+        [ 'element/enter:children', 'id4', 'bar' ],
+        [ 'element/exit:children',  'id4', 'bar' ],
+        [ 'element/exit',           'id4', 'bar' ],
+        [ 'element/exit:children',  'id3', 'bar' ],
+        [ 'comment/enter',          '',    'bar' ],
+        [ 'comment/exit',           '',    'bar' ],
+        [ 'element/exit',           'id3', 'bar' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/exit',           'id1', 'foo' ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'handles descendant instruction of in-element tree instruction',
+      template: [
+        '<div id="id1" {{! template-lint-configure-tree fake "foo" }}>',
+        '  <span id="id2">',
+        '    {{! template-lint-configure fake "bar" }}',
+        '    <i id="id3">',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', 'foo' ],
+        [ 'element/enter:children', 'id1', 'foo' ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'bar' ],
+        [ 'element/enter',          'id3', 'bar' ],
+        [ 'element/enter:children', 'id3', 'bar' ],
+        [ 'element/enter',          'id4', 'bar' ],
+        [ 'element/enter:children', 'id4', 'bar' ],
+        [ 'element/exit:children',  'id4', 'bar' ],
+        [ 'element/exit',           'id4', 'bar' ],
+        [ 'element/exit:children',  'id3', 'bar' ],
+        [ 'element/exit',           'id3', 'bar' ],
+        [ 'element/exit:children',  'id2', 'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/exit',           'id1', 'foo' ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'enable restores default config',
+      config: 'foo',
+      template: [
+        '<div id="id1">',
+        '  {{! template-lint-configure fake "bar" }}',
+        '  <span id="id2">',
+        '    {{! template-lint-disable fake }}',
+        '    <i id="id3">',
+        '      {{! template-lint-enable fake }}',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', 'foo' ],
+        [ 'element/enter:children', 'id1', 'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'bar' ],
+        [ 'element/enter',          'id2', 'bar' ],
+        [ 'element/enter:children', 'id2', 'bar' ],
+        [ 'comment/enter',          '',    'bar' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/enter',          'id4', 'foo' ],
+        [ 'element/enter:children', 'id4', 'foo' ],
+        [ 'element/exit:children',  'id4', 'foo' ],
+        [ 'element/exit',           'id4', 'foo' ],
+        [ 'element/exit:children',  'id2', 'bar' ],
+        [ 'element/exit',           'id2', 'bar' ],
+        [ 'element/exit:children',  'id1', 'foo' ],
+        [ 'element/exit',           'id1', 'foo' ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'enabling a disabled-by-default rule actually enables it',
+      config: false,
+      template: [
+        '<div id="id1">',
+        '  {{! template-lint-enable fake }}',
+        '  <span id="id2"></span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'comment/exit',           '',    true  ],
+        [ 'element/enter',          'id2', true  ],
+        [ 'element/enter:children', 'id2', true  ],
+        [ 'element/exit:children',  'id2', true  ],
+        [ 'element/exit',           'id2', true  ]
+      ]
+    });
+
+    // Not really a case that makes sense, but just to be sure it doesn't mess
+    // up the config stack
+    expectEvents({
+      desc: 'ensures this pretty silly case doesn\'t mess up the config stack',
+      template: [
+        '<div id="id1">',
+        '  <span id="id2" {{! template-lint-configure fake "bar" }} {{! template-lint-configure fake "foo" }}>',
+        '    <i id="id3">',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'element/enter',          'id2', 'foo' ],
+        [ 'element/enter:children', 'id2', true  ],
+        [ 'element/enter',          'id3', true  ],
+        [ 'element/enter:children', 'id3', true  ],
+        [ 'element/enter',          'id4', true  ],
+        [ 'element/enter:children', 'id4', true  ],
+        [ 'element/exit:children',  'id4', true  ],
+        [ 'element/exit',           'id4', true  ],
+        [ 'element/exit:children',  'id3', true  ],
+        [ 'element/exit',           'id3', true  ],
+        [ 'element/exit:children',  'id2', true  ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'comment/enter',          '',    'foo' ],
+        [ 'comment/exit',           '',    'foo' ],
+        [ 'element/exit',           'id2', 'foo' ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ]
+      ]
+    });
+
+    expectEvents({
+      desc: 'it doesn\'t call a disabled rule\'s visitor handlers',
+      template: [
+        '<div id="id1">',
+        '  <span id="id2" {{! template-lint-disable fake }}>',
+        '    <i id="id3">',
+        '      <b id="id4"/>',
+        '    </i>',
+        '  </span>',
+        '</div>'
+      ].join('\n'),
+      events: [
+        [ 'element/enter',          'id1', true  ],
+        [ 'element/enter:children', 'id1', true  ],
+        [ 'element/enter:children', 'id2', true  ],
+        [ 'element/enter',          'id3', true  ],
+        [ 'element/enter:children', 'id3', true  ],
+        [ 'element/enter',          'id4', true  ],
+        [ 'element/enter:children', 'id4', true  ],
+        [ 'element/exit:children',  'id4', true  ],
+        [ 'element/exit',           'id4', true  ],
+        [ 'element/exit:children',  'id3', true  ],
+        [ 'element/exit',           'id3', true  ],
+        [ 'element/exit:children',  'id2', true  ],
+        [ 'element/exit:children',  'id1', true  ],
+        [ 'element/exit',           'id1', true  ]
+      ]
+    });
   });
 });
diff --git a/test/unit/rules/lint-bare-strings-test.js b/test/unit/rules/lint-bare-strings-test.js
index 675ac03..8d352ec 100644
--- a/test/unit/rules/lint-bare-strings-test.js
+++ b/test/unit/rules/lint-bare-strings-test.js
@@ -28,8 +28,8 @@ generateRuleTests({
     '{{t "foo"}}',
     '{{t "foo"}}, {{t "bar"}} ({{length}})',
     '(),.&+-=*/#%!?:[]{}',
-    '<!-- template-lint bare-strings=false -->',
-    '<!-- template-lint enabled=false -->',
+    '{{! template-lint-disable bare-strings }}',
+    '{{! template-lint-disable }}',
 
     // placeholder is a <input> specific attribute
     '<div placeholder="wat?"></div>',
diff --git a/test/unit/rules/lint-block-indentation-test.js b/test/unit/rules/lint-block-indentation-test.js
index 98f19a8..7c31763 100644
--- a/test/unit/rules/lint-block-indentation-test.js
+++ b/test/unit/rules/lint-block-indentation-test.js
@@ -151,8 +151,8 @@ generateRuleTests({
         '\t<p>Hi!</p>\n' +
         '</div>'
     },
-    '<!-- template-lint bare-strings=false -->',
-    '<!-- template-lint enabled=false -->',
+    '{{! template-lint-disable bare-strings }}',
+    '{{! template-lint-disable }}',
     {
       template: [
         '<div>',
diff --git a/test/unit/rules/lint-html-comments-test.js b/test/unit/rules/lint-html-comments-test.js
index 5105b72..ac0f73e 100644
--- a/test/unit/rules/lint-html-comments-test.js
+++ b/test/unit/rules/lint-html-comments-test.js
@@ -10,8 +10,8 @@ generateRuleTests({
   good: [
     '{{!-- comment here --}}',
     '{{!--comment here--}}',
-    '<!-- template-lint bare-strings=false -->',
-    '<!-- template-lint enabled=false -->'
+    '{{! template-lint-disable bare-strings }}',
+    '{{! template-lint-disable }}'
   ],
 
   bad: [
diff --git a/test/unit/rules/lint-triple-curlies-test.js b/test/unit/rules/lint-triple-curlies-test.js
index 083f29e..597f1d1 100644
--- a/test/unit/rules/lint-triple-curlies-test.js
+++ b/test/unit/rules/lint-triple-curlies-test.js
@@ -9,8 +9,8 @@ generateRuleTests({
 
   good: [
     '{{foo}}',
-    '<!-- template-lint bare-strings=false -->',
-    '<!-- template-lint enabled=false -->'
+    '{{! template-lint-disable bare-strings }}',
+    '{{! template-lint-disable }}'
   ],
 
   bad: [