@chemx/starter-kit 26.10.10-75 → 26.10.19-530

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 (78) hide show
  1. package/AGENTS.md +5 -2
  2. package/README.md +2 -1
  3. package/blueprints/_vitest.config.svelte.ts +11 -0
  4. package/blueprints/_vitest.config.vue.ts +11 -0
  5. package/blueprints/molecule-capsule/index.svelte.ts +2 -0
  6. package/blueprints/molecule-capsule/index.vue.ts +2 -0
  7. package/blueprints/molecule-capsule/m-chemx-badge/index.svelte.ts +3 -0
  8. package/blueprints/molecule-capsule/m-chemx-badge/index.vue.ts +3 -0
  9. package/blueprints/molecule-capsule/m-sample-card.spec.svelte.ts +33 -0
  10. package/blueprints/shims-svelte.d.ts +3 -0
  11. package/cli/audit/base-delta.js +89 -0
  12. package/cli/audit/gate-verdict.js +11 -0
  13. package/cli/audit/hook-shape-validator.js +28 -2
  14. package/cli/audit/lexicon-predicates.js +41 -10
  15. package/cli/audit/rule-revisions.js +4 -2
  16. package/cli/audit/rules-registry.js +6 -0
  17. package/cli/audit/rules.js +6 -2
  18. package/cli/audit/staged-delta.js +20 -11
  19. package/cli/audit/tailwind-unscanned.js +328 -0
  20. package/cli/audit/types.d.ts +15 -1
  21. package/cli/build/reporter.js +5 -3
  22. package/cli/build/types.d.ts +2 -0
  23. package/cli/build.js +22 -2
  24. package/cli/commands/cmd-router.js +6 -0
  25. package/cli/commands/cmd-staged-delta.js +50 -5
  26. package/cli/commands/unknown-flags.js +1 -1
  27. package/cli/commands-schema-search.js +5 -0
  28. package/cli/commands-schema-verify.js +2 -2
  29. package/cli/generator-compact.js +3 -4
  30. package/cli/generator-templates/archetypes/layout-archetypes.js +1 -0
  31. package/cli/generator-templates/atom-import.js +31 -0
  32. package/cli/generator-templates/compact.js +7 -5
  33. package/cli/generator-templates/naming.js +16 -0
  34. package/cli/generator-templates/react.js +5 -4
  35. package/cli/generator-templates/svelte.js +6 -5
  36. package/cli/generator-templates/vue.js +8 -6
  37. package/cli/generator-templates.d.ts +4 -0
  38. package/cli/generator-templates.js +1 -1
  39. package/cli/generator.js +18 -2
  40. package/cli/hooks/session-identity.js +13 -1
  41. package/cli/job-line-run.js +99 -0
  42. package/cli/job-line-spec-helpers.js +73 -0
  43. package/cli/job-line-text.js +72 -0
  44. package/cli/job-line-tickets.js +125 -0
  45. package/cli/job-line-turns.js +131 -0
  46. package/cli/job-line-wiring-helpers.js +75 -0
  47. package/cli/job-line.js +218 -0
  48. package/cli/literal-replace.js +35 -2
  49. package/cli/mcp/antigravity.js +2 -1
  50. package/cli/mcp/help.js +2 -2
  51. package/cli/mcp/manifests.js +4 -0
  52. package/cli/mcp/tools-q.js +40 -2
  53. package/cli/mcp/tools-read.js +5 -0
  54. package/cli/mcp/tools-verify.js +47 -5
  55. package/cli/project-detector.js +11 -21
  56. package/cli/reader-cli.js +3 -0
  57. package/cli/reader.js +4 -6
  58. package/cli/scaffold-frameworks.js +25 -8
  59. package/cli/search-args.js +12 -6
  60. package/cli/search-ast.js +3 -2
  61. package/cli/search-commands-literal.js +56 -4
  62. package/cli/search-literal.js +59 -7
  63. package/cli/search.js +9 -1
  64. package/cli/slot-files.js +103 -0
  65. package/cli/symbol-locator.js +4 -3
  66. package/cli/test-audit.js +11 -8
  67. package/cli/test-command.js +20 -3
  68. package/cli/test-output.js +11 -0
  69. package/cli/test-report.js +20 -6
  70. package/cli/test-run.js +54 -18
  71. package/cli/test-slots.js +60 -97
  72. package/cli/test-workers.js +24 -4
  73. package/cli/typecheck-audit.js +19 -5
  74. package/cli/verify-changed.js +7 -0
  75. package/cli/verify-report.js +7 -1
  76. package/cli/verify-steps.js +7 -3
  77. package/cli/verify.js +15 -8
  78. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -8,7 +8,8 @@
8
8
  ## ⚡ Quick Agent Reflex Table (Token-Bounded Commands)
9
9
  | Standard Reflex | `chemx` Equivalent | Token & Architecture Advantage |
10
10
  | :--- | :--- | :--- |
11
- | `grep -rn "pattern" .` | `chemx q -g "pattern"` or `chemx q -g "pattern" -l` | Fixed-string search of every text file `.gitignore` allows (submodules, docs, styles, JSON, PHP); full `path:line:text` lines or line-only (-l); `--regex` opts into regex; `-- <pattern>` for a pattern starting with `-`. Uses `rg` when installed, a JS walker otherwise; the header states files searched and the engine. |
11
+ | `grep -rn "pattern" .` | `chemx q -g "pattern" --bare` or `chemx q -g "pattern" -C 3 --bare` | Fixed-string search matching rg token-for-token (57 chars / 14 tokens per hit); add `-C <N>` to inspect surrounding code in a single turn without a second read call. |
12
+ | `sed -n 'N,Mp' <file>` | `chemx read <file>:N-M --bare` | Verbatim slice matching sed byte-for-byte; 0 header, box or line number tokens. |
12
13
  | `git diff` | `chemx d` | Zero-context (`-U0`), auto-collapses to `--stat` if > 80 lines, micro-syncs index. |
13
14
  | `git log` | `chemx log -n 5` | One-line commit history (5 commits measured about 140 tokens on 2026-10-09, roughly 28 per commit). |
14
15
  | `find . -name "*.vue"` | `chemx f "*.vue"` | Strictly filters by `.gitignore` and ignored dirs. |
@@ -251,7 +252,7 @@ Use same-name shorthand across all languages; eliminate redundant `key: key` dup
251
252
  ### A. Named Conditions & Two-Stage Composition
252
253
  > The `if` never asks a question. The question is asked and named before the branch, and the `if` reads the answer.
253
254
 
254
- - Every conditional test is a named boolean: an identifier, its negation, or a call or member access whose name is an assertion (3.F). A raw comparison (`x === y`, `n >= 5`), a truthiness check on a non-boolean (`if (user)`, `if (obj.prop)`), inline compound logic (`a && b`), or a verb-named call (`RE.test(s)`, `fs.existsSync(p)`) inside the test is a violation.
255
+ - Every conditional test is a named boolean: an identifier, its negation, a bare property read (`sale.ok`, `items.length`), or a call whose name states an assertion or a predicate (3.F: `hasItems(cart)`, `map.has(key)`, `fs.existsSync(p)`). A raw comparison (`x === y`, `n >= 5`), inline compound logic (`a && b`), or a call whose name states no predicate (`RE.test(s)`, `user.getName()`) inside the test is a violation.
255
256
  - Naming is the point, not the ceremony: it forces the question inline code hides (why `<= 0` and not `=== -1`?), and an edit to a named condition changes its definition without touching the branch.
256
257
  - **Stage 1** names each atomic concept. **Stage 2** names the decision, only when 2 or more concepts compose; a single comparison gets one name and no wrapper. Guard with early returns.
257
258
  ```typescript
@@ -276,6 +277,7 @@ Use same-name shorthand across all languages; eliminate redundant `key: key` dup
276
277
  1. **Structural primitives** (`lib/is/`: `value`, `collection`, `text`, `fs`, `type`): subject-agnostic and finite (`isAbsent`, `hasItems`, `isNonEmptyString`, `pathExists`). They import nothing, and the set does not grow. Every primitive is a TypeScript type predicate (`(x: unknown): x is string`), never a plain `boolean`, or callers lose narrowing.
277
278
  2. **Domain vocabulary** (`<subsystem>/<domain>-predicates.<ext>`, colocated with the subsystem it describes): built from Layer 1. A condition earns a domain predicate at its 2nd use; a threshold duplicated across files (`score >= 90`) silently disagrees the day one copy changes, so name it once (`isGradeA`). Promote threshold literals in predicate bodies to named constants (`score >= GRADE_A_THRESHOLD`).
278
279
  3. **Decisions**: single-use composites declared at the call site per 3.A. Never extract them: extracting every condition is *indirection masquerading as modularity* (1.A).
280
+ - A bare property read (`sale.ok`, `items.length`) or a predicate-method call (`map.has(key)`, `fs.existsSync(p)`) is already a named condition; 3.A does not ask for a const alias of it.
279
281
  - Before naming a new predicate, search for an existing one: `chemx q -g "<likely words>"` for the literal names, plus `chemx({ action: 'q', params: { query: '<concept>', hybrid: true } })` for spelling variants. Neither finds pure synonyms (`hasNoItems` vs `isEmpty`), so also scan the predicates module of the domain. A synonym beside an existing predicate is lexicon rot.
280
282
  - Repeated filter conditions are Layer 2 candidates: extract the predicate, compose a named higher-order filter, and keep derivations declarative:
281
283
  ```typescript
@@ -416,6 +418,7 @@ Raw inline `style="..."` attributes are strictly prohibited. Visual styling flow
416
418
  - Long strings of utility shorthand recreate the exact cognitive noise and AI context degradation of inline styles.
417
419
  - Decouple visual styling into scoped classes via `@apply` or centralized SCSS mixins (`@include glass;`).
418
420
  - Templates must read like a clean, semantic outline rather than an unreadable wall of styling shorthand.
421
+ - Class strings must stay where Tailwind looks (`TAILWIND_UNSCANNED_CLASS_STRING`): in a Tailwind v3 project, a string of 3 or more utility classes in a `.js`, `.ts`, `.mjs` or `.cjs` file that no `content` glob of `tailwind.config` matches is purged from production CSS. Move it into a template or a file the globs scan, or add the file pattern to `content`. The rule reads the config as text and stays silent for Tailwind v4 or a config whose `content` list is not literal.
419
422
 
420
423
  ### C. Wrapper Atoms & Explicit Slot Forwarding
421
424
  - Never use dynamic slot iteration with `v-for="(_, slot) in $slots"` in wrapper components.
package/README.md CHANGED
@@ -180,7 +180,7 @@ chemx build --json
180
180
 
181
181
  #### One gate rule
182
182
 
183
- The pre-commit hook (`chemx audit --staged-delta`), the ratchet step of `chemx verify` (`chemx-ratchet.json`), `chemx team task done` and the `introducedViolations` list in `patch`/`write` results share one rule: a change fails when any rule's violation count rises in a file, at any severity. A commit the hook accepts therefore cannot turn the ratchet step red. `CHEMX_SKIP_PRECOMMIT=1` skips only the hook; `chemx verify` still applies the rule. On failure the hook prints each new hazard as `RULE@file:line`. Guarantees, non-guarantees and the specs that prove parity are in [docs/audit-gates.md](docs/audit-gates.md).
183
+ The pre-commit hook (`chemx audit --staged-delta`), the base delta gate (`chemx audit --delta-base=<ref>`, `chemx verify --changed --base=<ref>`), the ratchet step of `chemx verify` (`chemx-ratchet.json`), `chemx team task done` and the `introducedViolations` list in `patch`/`write` results share one rule: a change fails when any rule's violation count rises in a file, at any severity. A commit the hook accepts therefore cannot turn the ratchet step red. The base delta gate scores only the files a branch changed against their merge-base, so a branch that fixes one rule stays green even while the trunk is over the ratchet on other rules. `CHEMX_SKIP_PRECOMMIT=1` skips only the hook; `chemx verify` still applies the rule. On failure the hook prints each new hazard as `RULE@file:line`. Guarantees, non-guarantees and the specs that prove parity are in [docs/audit-gates.md](docs/audit-gates.md).
184
184
 
185
185
  #### Interactive audit navigator
186
186
 
@@ -335,6 +335,7 @@ Chemical X audits more than one language. The table below lists the extensions i
335
335
  2. **Tier 2: Deep Language-Specific Analyzers**
336
336
  * **Babel Engine:** Deep AST inspection for JS/TS/Vue/Svelte (runs on JS, TS, Vue and Svelte files only).
337
337
  * **Control Flow & Guard Disciplines (`CONTROL_FLOW_CASCADE_GUARDS`, `CONTROL_FLOW_SILENT_GUARD`):** Flags silent bare returns in side-effecting code, and detects cascading early-return clusters (>= 3 guards), recommending extraction into `ruleTree` with 1-line callback aborts (Directive 3.H).
338
+ * **Tailwind content coverage (`TAILWIND_UNSCANNED_CLASS_STRING`):** In a Tailwind v3 project, flags a string literal of 3 or more utility classes (2 or more of them Tailwind-shaped) in a `.js`, `.ts`, `.mjs` or `.cjs` file that no `content` glob of `tailwind.config` covers, since Tailwind purges classes it never scans. The config is read as text and never imported; Tailwind v4, a missing config or a non-literal `content` list keep the rule silent. HIGH, Directive 5.B. `options.tailwindContent` overrides the globs for tests.
338
339
  * **C# / Clean Architecture Analyzer:** Flags empty `catch (Exception) {}` blocks, simulated delays (`Task.Delay`), and monolithic controllers.
339
340
  * **Swallowed catches (`ERROR_SWALLOWED_EXCEPTION`, `AI_SLOP_SHALLOW_CATCH`):** Each swallowing catch site reports once, as `ERROR_SWALLOWED_EXCEPTION` when the error is discarded or `AI_SLOP_SHALLOW_CATCH` when it is only logged to the console. Both are MEDIUM by default. A JS/TS catch escalates to HIGH when a `let` or `var` assigned in the try is read after it with no default or check first (silent `undefined` propagation); C# findings stay MEDIUM. Mark an intentional swallow with a `chemx-allow: best-effort <reason>` comment on the line above the catch, on the catch line, inside its body, or after the try block's closing brace when `catch` starts the next line. Only comments count (never string literals), and the reason is mandatory: an annotation without one is still flagged and its hazard says so.
340
341
 
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
3
+
4
+ export default defineConfig({
5
+ plugins: [svelte()],
6
+ test: {
7
+ globals: true,
8
+ environment: 'node',
9
+ include: ['**/*.spec.ts', '**/*.test.ts']
10
+ }
11
+ });
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import vue from '@vitejs/plugin-vue';
3
+
4
+ export default defineConfig({
5
+ plugins: [vue()],
6
+ test: {
7
+ globals: true,
8
+ environment: 'jsdom',
9
+ include: ['**/*.spec.ts', '**/*.test.ts']
10
+ }
11
+ });
@@ -0,0 +1,2 @@
1
+ export { default as MSampleCard } from './m-sample-card.svelte';
2
+ export type { MSampleCardProps, SampleCardBadgeDescriptor } from './types';
@@ -0,0 +1,2 @@
1
+ export { default as MSampleCard } from './m-sample-card.vue';
2
+ export type { MSampleCardProps, SampleCardBadgeDescriptor } from './types';
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export { default as MChemxBadge } from './m-chemx-badge.svelte';
3
+ export { default } from './m-chemx-badge.svelte';
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export { default as MChemxBadge } from './m-chemx-badge.vue';
3
+ export { default } from './m-chemx-badge.vue';
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from 'svelte/server';
3
+ import MSampleCard from './m-sample-card.svelte';
4
+
5
+ describe('m-sample-card: MSampleCard Component', () => {
6
+ it('renders title, formatted value, and standard badge correctly', () => {
7
+ const { body } = render(MSampleCard, {
8
+ props: { title: 'Core Subscription', subtitle: 'Monthly compute tier', value: 750 }
9
+ });
10
+
11
+ expect(body).toContain('class="m-sample-card"');
12
+ expect(body).toContain('Core Subscription');
13
+ expect(body).toContain('Monthly compute tier');
14
+ expect(body).toContain('$750');
15
+ expect(body).toContain('m-sample-card__badge--standard');
16
+ expect(body).not.toContain('m-sample-card__action');
17
+ });
18
+
19
+ it('renders high-value modifier when numeric value exceeds 1000', () => {
20
+ const { body } = render(MSampleCard, { props: { title: 'Enterprise Cluster', value: 2500 } });
21
+
22
+ expect(body).toContain('Enterprise Cluster');
23
+ expect(body).toContain('$2,500');
24
+ expect(body).toContain('m-sample-card__badge--high-value');
25
+ });
26
+
27
+ it('renders the action button only when onAction is given', () => {
28
+ const { body } = render(MSampleCard, { props: { title: 'Actionable Unit', value: 1200, onAction: () => undefined } });
29
+
30
+ expect(body).toContain('m-sample-card__action');
31
+ expect(body).toContain('Action');
32
+ });
33
+ });
@@ -0,0 +1,3 @@
1
+ // Svelte ships the `*.svelte` module declaration; this reference loads it for every tsc run,
2
+ // including one where no .ts file imports from 'svelte' itself.
3
+ /// <reference types="svelte" />
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Delta gate against a base ref (#6091): each changed source file's working-tree content
3
+ * is audited against the same file at the base, and the gate fails only when a rule's
4
+ * violation count rises in a file, at any severity (gate-delta.js, the rule the pre-commit
5
+ * hook and the ratchet apply). Hazards the base already has never count, so a change that
6
+ * fixes one rule is not held red by other rules the repo-wide ratchet fails on.
7
+ *
8
+ * The base is merge-base(<ref>, HEAD) when there is one, the same starting point
9
+ * `verify --changed --base=<ref>` uses to list changed files; otherwise <ref> itself.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { loadProjectConfig } from '../config/index.js';
14
+ import { isSourceFilePath } from '../audit-preflight-git.js';
15
+ import { evaluateChanges, formatSites } from './gate-delta.js';
16
+ import { gitText, parseNameStatus, changeFromContents } from './staged-delta.js';
17
+
18
+ const DIFF_ARGS = ['diff', '--name-status', '-z', '-M', '--diff-filter=ACMR', '--relative'];
19
+
20
+ /** { label, rev } for an existing commit-ish, null when `baseRef` names none. A leading `-` is never a ref. */
21
+ const resolveBase = (cwd, baseRef) => {
22
+ const isOptionLike = typeof baseRef !== 'string' || baseRef === '' || baseRef.startsWith('-');
23
+ if (isOptionLike) return null;
24
+ const commit = gitText(cwd, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
25
+ const isMissing = commit === null;
26
+ if (isMissing) return null;
27
+ const mergeBase = gitText(cwd, ['merge-base', baseRef, 'HEAD']);
28
+ return { label: baseRef, rev: (mergeBase ?? commit).trim() };
29
+ };
30
+
31
+ /** Untracked, non-ignored source files: new content in the working tree that git diff does not list. */
32
+ const listUntrackedSources = (cwd) =>
33
+ (gitText(cwd, ['ls-files', '--others', '--exclude-standard', '-z']) ?? '')
34
+ .split('\0')
35
+ .filter((file) => file !== '' && isSourceFilePath(file))
36
+ .map((file) => ({ file, basePath: null, isRename: false }));
37
+
38
+ /** Changed source files vs `rev` as { file, basePath, isRename }, paths relative to `cwd`; a file is listed once. */
39
+ const listBaseSources = (cwd, rev) => {
40
+ const tracked = parseNameStatus(gitText(cwd, [...DIFF_ARGS, rev, '--']) ?? '');
41
+ const seen = new Set(tracked.map((entry) => entry.file));
42
+ return [...tracked, ...listUntrackedSources(cwd).filter((entry) => !seen.has(entry.file))];
43
+ };
44
+
45
+ const readWorkingTree = (cwd, file) => {
46
+ const absPath = path.join(cwd, file);
47
+ const isFile = fs.statSync(absPath, { throwIfNoEntry: false })?.isFile() === true;
48
+ return isFile ? fs.readFileSync(absPath, 'utf-8') : null;
49
+ };
50
+
51
+ const toChange = (cwd, rev, entry, config) => {
52
+ const afterText = readWorkingTree(cwd, entry.file);
53
+ const isReadable = afterText !== null;
54
+ if (!isReadable) return null;
55
+ const baseText = entry.basePath ? gitText(cwd, ['show', `${rev}:./${entry.basePath}`]) ?? '' : '';
56
+ return changeFromContents(cwd, entry, { baseText, afterText, config });
57
+ };
58
+
59
+ /**
60
+ * Per-rule delta of the working tree against `baseRef`. `options.config` replaces the project
61
+ * config loaded from `cwd` and `rawArgs` (verify passes the one it already holds).
62
+ *
63
+ * @returns {{ isPassing: boolean, base: string, rev?: string, checked: number, files: object[], baseMissing?: boolean, error?: string }}
64
+ * An unknown ref never throws and never passes: it returns baseMissing with isPassing false, so
65
+ * a typo cannot turn the gate green.
66
+ */
67
+ export const evaluateBaseDelta = (cwd = process.cwd(), baseRef, rawArgs = [], options = {}) => {
68
+ const resolved = resolveBase(cwd, baseRef);
69
+ const isMissing = resolved === null;
70
+ if (isMissing) return { isPassing: false, base: baseRef ?? null, checked: 0, files: [], baseMissing: true, error: `unknown base revision "${baseRef ?? ''}"` };
71
+ const config = options.config ?? loadProjectConfig(cwd, rawArgs);
72
+ const entries = listBaseSources(cwd, resolved.rev);
73
+ const changes = entries.map((entry) => toChange(cwd, resolved.rev, entry, config)).filter(Boolean);
74
+ return { ...evaluateChanges(changes), base: resolved.label, rev: resolved.rev, checked: changes.length };
75
+ };
76
+
77
+ /** The delta as the compact block `verify --json` carries: rising rules and one `RULE@file:line [SEVERITY]` per new hazard. */
78
+ export const summarizeBaseDelta = (delta) => ({
79
+ base: delta.base,
80
+ passing: delta.isPassing,
81
+ checked: delta.checked,
82
+ ...(delta.error ? { error: delta.error } : {}),
83
+ files: delta.files.map((entry) => ({
84
+ file: entry.file,
85
+ ...(entry.renamedFrom ? { renamedFrom: entry.renamedFrom } : {}),
86
+ increases: entry.increases.map(({ rule, severity, before, after }) => ({ rule, severity, before, after })),
87
+ sites: formatSites(entry)
88
+ }))
89
+ });
@@ -27,6 +27,17 @@ export const evaluateGateVerdict = ({ violations, ratchetEval }) => {
27
27
  return { isPassing: !hasSevereViolation, basis: 'severity', regressions: [], adopted: [], note: ratchetEval.message ?? null };
28
28
  };
29
29
 
30
+ /**
31
+ * The verdict of `verify --changed --base=<ref>` (#6091): the per-rule delta against the base
32
+ * decides, at any severity, and the repo-wide ratchet and the severity gate are not consulted.
33
+ * `delta` is an evaluateBaseDelta result.
34
+ */
35
+ export const evaluateDeltaVerdict = (delta) => {
36
+ const risen = delta.files.length;
37
+ const note = delta.baseMissing ? delta.error : `${risen} changed file(s) raise a rule's violation count vs ${delta.base}`;
38
+ return { isPassing: delta.isPassing, basis: 'delta', regressions: [], adopted: [], note: delta.isPassing ? null : note };
39
+ };
40
+
30
41
  const PARTIAL_SCAN_EVAL = {
31
42
  status: 'partial',
32
43
  regressions: [],
@@ -1,14 +1,40 @@
1
1
  import * as t from '@babel/types';
2
2
  import { RULE_REGISTRY } from './rules-registry.js';
3
3
 
4
- const VERB_PREFIX_REGEX = /^(add|remove|toggle|set|get|fetch|load|save|delete|clear|reset|refresh|update|create|edit|send|sync|init|setup|dispose|trigger|post|put|patch|apply|dismiss|select|deselect|filter|sort|search|open|close|show|hide|start|stop|cancel|execute|run|retry|submit|validate|mutate|next|prev|step|increment|decrement|insert|upsert|drop|enable|disable|register|unregister|login|logout|checkout|publish|archive|discard|confirm|undo|redo|claim|complete|acquire|release|download|upload|export|import|handle|play|pause|resume|seek|unlock|restore|advance|focus|notify|broadcast|copy|format)([A-Z0-9_].*)?$/;
4
+ export const HOOK_ACTION_VERBS = [
5
+ 'abort', 'accept', 'acquire', 'activate', 'add', 'advance', 'align', 'animate', 'append', 'apply',
6
+ 'approve', 'archive', 'assign', 'attach', 'begin', 'bind', 'boot', 'broadcast', 'build', 'calculate',
7
+ 'cancel', 'center', 'check', 'checkout', 'choose', 'claim', 'clear', 'clone', 'close', 'collapse',
8
+ 'complete', 'compute', 'conceal', 'confirm', 'connect', 'convert', 'copy', 'create', 'deactivate', 'debounce',
9
+ 'decline', 'decrement', 'defer', 'delete', 'dequeue', 'deselect', 'detach', 'disable', 'discard', 'disconnect',
10
+ 'dismiss', 'dispatch', 'dispose', 'download', 'drag', 'draw', 'drop', 'duplicate', 'edit', 'emit',
11
+ 'enable', 'end', 'enqueue', 'ensure', 'evict', 'execute', 'expand', 'export', 'fetch', 'fill',
12
+ 'filter', 'finalize', 'finish', 'fit', 'flag', 'flip', 'flush', 'focus', 'follow', 'fork',
13
+ 'format', 'generate', 'get', 'go', 'grant', 'halt', 'handle', 'hide', 'highlight', 'hydrate',
14
+ 'import', 'increment', 'init', 'insert', 'invalidate', 'invite', 'join', 'jump', 'kill', 'launch',
15
+ 'leave', 'link', 'load', 'lock', 'log', 'login', 'logout', 'mark', 'maximize', 'measure',
16
+ 'merge', 'minimize', 'mount', 'move', 'mutate', 'mute', 'navigate', 'next', 'notify', 'open',
17
+ 'parse', 'patch', 'pause', 'persist', 'pick', 'pin', 'play', 'poll', 'pop', 'populate',
18
+ 'post', 'prefetch', 'prepare', 'prepend', 'prev', 'preview', 'print', 'publish', 'purge', 'push',
19
+ 'put', 'queue', 'read', 'record', 'redirect', 'redo', 'refetch', 'refresh', 'register', 'reject',
20
+ 'release', 'reload', 'remove', 'rename', 'render', 'reorder', 'replace', 'reply', 'report', 'reposition',
21
+ 'request', 'reset', 'resize', 'resolve', 'restart', 'restore', 'resume', 'retry', 'revalidate', 'reveal',
22
+ 'revoke', 'rewind', 'rotate', 'run', 'save', 'scan', 'schedule', 'scroll', 'search', 'seed',
23
+ 'seek', 'select', 'send', 'set', 'setup', 'share', 'show', 'shuffle', 'shutdown', 'skip',
24
+ 'snap', 'sort', 'spawn', 'split', 'start', 'step', 'stop', 'stroke', 'submit', 'subscribe',
25
+ 'swap', 'sync', 'throttle', 'toggle', 'track', 'transform', 'translate', 'trigger', 'unbind', 'undo',
26
+ 'unfollow', 'unlink', 'unlock', 'unmount', 'unmute', 'unpin', 'unregister', 'unsubscribe', 'unwatch', 'update',
27
+ 'upload', 'upsert', 'validate', 'verify', 'wait', 'watch', 'write', 'zoom',
28
+ ];
29
+
30
+ const VERB_PREFIX_REGEX = new RegExp(`^(${HOOK_ACTION_VERBS.join('|')})([A-Z0-9_].*)?$`);
5
31
 
6
32
  const STATUS_HEURISTIC_REGEX = /^(is[A-Z]|has[A-Z]|can[A-Z]|should[A-Z])|.*(Error|Status)$/;
7
33
  const STATUS_EXACT_NAMES = new Set(['status', 'error', 'loading', 'pending', 'ready']);
8
34
  const ACTION_WRAPPER_KEYS = new Set(['actions', 'handlers', 'methods', 'operations']);
9
35
 
10
36
  const RAW_DOM_REF_REGEX = /^(dom|element|container|input|button|target|root|node)?Ref$|.*(Element|Node|Dom)Ref$/i;
11
- const INTERMEDIATE_VAL_REGEX = /^(temp|draft|raw|partial|interim|scratch|memoized)([A-Z_].*)?$/i;
37
+ const INTERMEDIATE_VAL_REGEX = /^(temp|draft|raw|partial|interim|scratch|memoized)([A-Z0-9_].*)?$/;
12
38
  const UNGROUPED_CALLBACK_REGEX = /^(on[A-Z]|callback$|.*Callback$)/;
13
39
 
14
40
  const MUTUALLY_EXCLUSIVE_STATUSES = new Set([
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * The named-condition lexicon from AGENTS.md 3.A and 3.F, as AST predicates.
3
- * A conditional test passes when it is an identifier, its negation, or a call or
4
- * member access whose name is an assertion (prefix followed by its subject).
3
+ * A conditional test passes when it is an identifier, its negation, a member
4
+ * chain with no operators (a property read is already a name), or a call whose
5
+ * callee name is an assertion, a standard predicate method, or carries a
6
+ * predicate word (`fs.existsSync(p)`, `map.has(key)`).
5
7
  */
6
8
  import * as t from '@babel/types';
7
9
 
@@ -13,6 +15,21 @@ export const ASSERTION_PREFIXES = [
13
15
 
14
16
  const ASSERTION_NAME = new RegExp(`^(?:${ASSERTION_PREFIXES.join('|')})[A-Z0-9_$]`);
15
17
  const TRANSPARENT_MEMBERS = new Set(['value', 'current']);
18
+
19
+ /** Standard-library methods that return a boolean by name alone (`test` is deliberately absent). */
20
+ export const PREDICATE_METHODS = new Set([
21
+ 'has', 'includes', 'some', 'every', 'startsWith', 'endsWith',
22
+ 'isArray', 'isNaN', 'isFinite', 'isInteger', 'isSafeInteger'
23
+ ]);
24
+
25
+ /** Words that make a verb-named call read as a question (`existsSync`, `isPathValid`). */
26
+ export const PREDICATE_WORDS = new Set([
27
+ 'exists', 'equals', 'matches', 'valid', 'ok', 'enabled', 'disabled', 'active',
28
+ 'visible', 'hidden', 'ready', 'empty', 'defined', 'present', 'loaded', 'done',
29
+ 'complete', 'dirty', 'stale', 'expired', 'pending', 'selected', 'checked',
30
+ 'open', 'closed', 'locked', 'found', 'known', 'allowed', 'blocked', 'accepted',
31
+ 'passed', 'failed'
32
+ ]);
16
33
  const ERRORISH_WORDS = new Set([
17
34
  'err', 'error', 'errors', 'fail', 'fails', 'failed', 'failure', 'exception',
18
35
  'invalid', 'rejected', 'fault', 'denied'
@@ -50,11 +67,27 @@ const isAssertionMember = (member) => {
50
67
  return isAssertionName(propName);
51
68
  };
52
69
 
53
- const isAssertionCall = (call) => {
70
+ const isMemberNode = (n) => t.isMemberExpression(n) || t.isOptionalMemberExpression(n);
71
+ const isCallNode = (n) => t.isCallExpression(n) || t.isOptionalCallExpression(n);
72
+
73
+ const isPredicateName = (name) => isAssertionName(name) || PREDICATE_METHODS.has(name) ||
74
+ splitNameWords(name).some((word) => PREDICATE_WORDS.has(word));
75
+
76
+ const isPredicateCall = (call) => {
54
77
  const callee = unwrapExpression(call.callee);
55
- if (t.isIdentifier(callee)) return isAssertionName(callee.name);
56
- const isMemberCallee = t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee);
57
- return isMemberCallee && isAssertionName(memberPropertyName(callee));
78
+ if (t.isIdentifier(callee)) return isPredicateName(callee.name);
79
+ return isMemberNode(callee) && isPredicateName(memberPropertyName(callee));
80
+ };
81
+
82
+ /** A chain of reads and calls rooted at a name, with no operator anywhere along it. */
83
+ const isPlainChain = (node) => {
84
+ const expr = unwrapExpression(node);
85
+ if (!expr) return false;
86
+ const isRoot = t.isIdentifier(expr) || t.isThisExpression(expr) || t.isSuper(expr) || t.isMetaProperty(expr);
87
+ if (isRoot) return true;
88
+ if (isMemberNode(expr)) return isPlainChain(expr.object);
89
+ if (isCallNode(expr)) return isPlainChain(expr.callee);
90
+ return false;
58
91
  };
59
92
 
60
93
  /** True when a test reads a named boolean per AGENTS.md 3.A. */
@@ -64,10 +97,8 @@ export const isNamedCondition = (node) => {
64
97
  if (t.isIdentifier(expr)) return true;
65
98
  const isNegation = t.isUnaryExpression(expr, { operator: '!' });
66
99
  if (isNegation) return isNamedCondition(expr.argument);
67
- const isMember = t.isMemberExpression(expr) || t.isOptionalMemberExpression(expr);
68
- if (isMember) return isAssertionMember(expr);
69
- const isCall = t.isCallExpression(expr) || t.isOptionalCallExpression(expr);
70
- if (isCall) return isAssertionCall(expr);
100
+ if (isMemberNode(expr)) return isPlainChain(expr) || isAssertionMember(expr);
101
+ if (isCallNode(expr)) return isPredicateCall(expr);
71
102
  return false;
72
103
  };
73
104
 
@@ -12,7 +12,7 @@
12
12
  * a rule without a bump fails the suite. At runtime a baseline also records every
13
13
  * registered rule's revision, so a rule it never saw is adopted, not a regression.
14
14
  */
15
- export const RULESET_VERSION = 5;
15
+ export const RULESET_VERSION = 6;
16
16
 
17
17
  export const RULE_REVISIONS = Object.freeze({
18
18
  // Ruleset 2: the 40-year canon rules (a3a1ac9, 9461ed2).
@@ -36,7 +36,9 @@ export const RULE_REVISIONS = Object.freeze({
36
36
  ERROR_SWALLOWED_EXCEPTION: 4,
37
37
  AI_SLOP_SHALLOW_CATCH: 4,
38
38
  // Ruleset 5: cascading guard detection and domain validator extraction (Directive 3.H).
39
- CONTROL_FLOW_CASCADE_GUARDS: 5
39
+ CONTROL_FLOW_CASCADE_GUARDS: 5,
40
+ // Ruleset 6: Tailwind content-glob coverage (review 2026-10-15).
41
+ TAILWIND_UNSCANNED_CLASS_STRING: 6
40
42
  });
41
43
 
42
44
  export const resolveRuleRevision = (rule) => RULE_REVISIONS[rule] ?? 1;
@@ -232,6 +232,12 @@ export const RULE_REGISTRY = {
232
232
  needs: 'light',
233
233
  directive: 'Avoid text-* utility classes on FontAwesome icons; use color prop or CSS'
234
234
  },
235
+ TAILWIND_UNSCANNED_CLASS_STRING: {
236
+ pillar: PILLARS.PILLAR_5,
237
+ severity: 'HIGH',
238
+ needs: 'standard',
239
+ directive: 'Move Tailwind class strings into a template or a file the Tailwind content globs scan, or add the file pattern to content'
240
+ },
235
241
  TYPOGRAPHY_EM_DASH: {
236
242
  pillar: PILLARS.PILLAR_7,
237
243
  severity: 'LOW',
@@ -15,6 +15,7 @@ import { checkTemplateRenderDepth } from './render-depth.js';
15
15
  import { parseSfc, isSfcFile } from '../sfc/sfc-parse.js';
16
16
  import { COVERAGE_KINDS, recordFileCoverage } from './coverage.js';
17
17
  import { parseScriptAsts, runAstPasses } from './ast-passes.js';
18
+ import { runTailwindUnscannedPass } from './tailwind-unscanned.js';
18
19
 
19
20
  export { PILLARS, RULE_REGISTRY, createHookShapeRegistry };
20
21
 
@@ -91,6 +92,7 @@ const collectRawViolations = (content, filePath, relativePath, options, ruleConf
91
92
  coverageKind = COVERAGE_KINDS.PARSE_ERROR;
92
93
  } else {
93
94
  runAstPasses(parsed.asts, { relativePath, violations, ruleConfig, options, code });
95
+ runTailwindUnscannedPass(parsed.asts, { filePath, relativePath, violations, options });
94
96
  }
95
97
  }
96
98
 
@@ -101,8 +103,10 @@ const collectRawViolations = (content, filePath, relativePath, options, ruleConf
101
103
  };
102
104
 
103
105
  /**
104
- * Audits one file. options: { config, fast, patternRegistry, hookRegistry, coverage, fingerprint }
105
- * (fingerprint: a Forge collector from cli/forge/fingerprint-visitors.js, or null to skip Forge).
106
+ * Audits one file. options: { config, fast, patternRegistry, hookRegistry, coverage, fingerprint, cwd, tailwindContent }
107
+ * (fingerprint: a Forge collector from cli/forge/fingerprint-visitors.js, or null to skip Forge;
108
+ * cwd: the project root for the Tailwind content probe; tailwindContent: content globs overriding
109
+ * tailwind.config, or null to silence TAILWIND_UNSCANNED_CLASS_STRING).
106
110
  * Violations pass through chemx-allow annotations, dedup and per-rule config.
107
111
  */
108
112
  export const auditCode = (content, filePath, relativePath, options = {}) => {
@@ -12,18 +12,19 @@ import { loadProjectConfig } from '../config/index.js';
12
12
  import { isSourceFilePath } from '../audit-preflight-git.js';
13
13
  import { evaluateChanges } from './gate-delta.js';
14
14
 
15
- const gitText = (cwd, args) => {
15
+ export const gitText = (cwd, args) => {
16
16
  const result = spawnSync('git', args, { cwd, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
17
17
  return result.status === 0 ? result.stdout : null;
18
18
  };
19
19
 
20
20
  /**
21
- * Staged additions, copies, modifications and renames as { file, basePath }. A rename
22
- * (git mv plus edits) is compared with HEAD at its old path, so it can neither slip
23
- * past the gate nor count its inherited hazards as new. `-z` keeps odd paths intact.
21
+ * Additions, copies, modifications and renames from `git diff --name-status -z -M --diff-filter=ACMR`
22
+ * output as { file, basePath, isRename }. A rename (git mv plus edits) is compared with the base at
23
+ * its old path, so it can neither slip past a gate nor count its inherited hazards as new; a copy is
24
+ * new content and has no base. `-z` keeps odd paths intact. Shared by the staged and the base-ref gate.
24
25
  */
25
- const listStagedSources = (cwd) => {
26
- const fields = (gitText(cwd, ['diff', '--cached', '--name-status', '-z', '-M', '--diff-filter=ACMR']) ?? '').split('\0');
26
+ export const parseNameStatus = (output) => {
27
+ const fields = output.split('\0');
27
28
  const entries = [];
28
29
  let i = 0;
29
30
  while (i < fields.length && fields[i]) {
@@ -39,15 +40,23 @@ const listStagedSources = (cwd) => {
39
40
  return entries.filter((entry) => isSourceFilePath(entry.file));
40
41
  };
41
42
 
42
- const toChange = (cwd, { file, basePath, isRename }, config) => {
43
+ const listStagedSources = (cwd) =>
44
+ parseNameStatus(gitText(cwd, ['diff', '--cached', '--name-status', '-z', '-M', '--diff-filter=ACMR']) ?? '');
45
+
46
+ /** One evaluateChanges entry: `baseText` audited as the before state, `afterText` as the after state of `file`. */
47
+ export const changeFromContents = (cwd, { file, basePath, isRename }, { baseText, afterText, config }) => {
43
48
  const absPath = path.join(cwd, file);
44
- const staged = gitText(cwd, ['show', `:${file}`]) ?? '';
45
- const base = basePath ? gitText(cwd, ['show', `HEAD:${basePath}`]) ?? '' : '';
46
- const before = auditCode(base, absPath, file, { config });
47
- const after = auditCode(staged, absPath, file, { config });
49
+ const before = auditCode(baseText, absPath, file, { config });
50
+ const after = auditCode(afterText, absPath, file, { config });
48
51
  return isRename ? { file, renamedFrom: basePath, before, after } : { file, before, after };
49
52
  };
50
53
 
54
+ const toChange = (cwd, entry, config) => {
55
+ const afterText = gitText(cwd, ['show', `:${entry.file}`]) ?? '';
56
+ const baseText = entry.basePath ? gitText(cwd, ['show', `HEAD:${entry.basePath}`]) ?? '' : '';
57
+ return changeFromContents(cwd, entry, { baseText, afterText, config });
58
+ };
59
+
51
60
  /**
52
61
  * Hazard increases in `currentViolations` over the committed HEAD version of `relPath` (empty base
53
62
  * when the file is new). Returns [] when there is no HEAD commit to compare with, so callers