@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.
- package/AGENTS.md +5 -2
- package/README.md +2 -1
- package/blueprints/_vitest.config.svelte.ts +11 -0
- package/blueprints/_vitest.config.vue.ts +11 -0
- package/blueprints/molecule-capsule/index.svelte.ts +2 -0
- package/blueprints/molecule-capsule/index.vue.ts +2 -0
- package/blueprints/molecule-capsule/m-chemx-badge/index.svelte.ts +3 -0
- package/blueprints/molecule-capsule/m-chemx-badge/index.vue.ts +3 -0
- package/blueprints/molecule-capsule/m-sample-card.spec.svelte.ts +33 -0
- package/blueprints/shims-svelte.d.ts +3 -0
- package/cli/audit/base-delta.js +89 -0
- package/cli/audit/gate-verdict.js +11 -0
- package/cli/audit/hook-shape-validator.js +28 -2
- package/cli/audit/lexicon-predicates.js +41 -10
- package/cli/audit/rule-revisions.js +4 -2
- package/cli/audit/rules-registry.js +6 -0
- package/cli/audit/rules.js +6 -2
- package/cli/audit/staged-delta.js +20 -11
- package/cli/audit/tailwind-unscanned.js +328 -0
- package/cli/audit/types.d.ts +15 -1
- package/cli/build/reporter.js +5 -3
- package/cli/build/types.d.ts +2 -0
- package/cli/build.js +22 -2
- package/cli/commands/cmd-router.js +6 -0
- package/cli/commands/cmd-staged-delta.js +50 -5
- package/cli/commands/unknown-flags.js +1 -1
- package/cli/commands-schema-search.js +5 -0
- package/cli/commands-schema-verify.js +2 -2
- package/cli/generator-compact.js +3 -4
- package/cli/generator-templates/archetypes/layout-archetypes.js +1 -0
- package/cli/generator-templates/atom-import.js +31 -0
- package/cli/generator-templates/compact.js +7 -5
- package/cli/generator-templates/naming.js +16 -0
- package/cli/generator-templates/react.js +5 -4
- package/cli/generator-templates/svelte.js +6 -5
- package/cli/generator-templates/vue.js +8 -6
- package/cli/generator-templates.d.ts +4 -0
- package/cli/generator-templates.js +1 -1
- package/cli/generator.js +18 -2
- package/cli/hooks/session-identity.js +13 -1
- package/cli/job-line-run.js +99 -0
- package/cli/job-line-spec-helpers.js +73 -0
- package/cli/job-line-text.js +72 -0
- package/cli/job-line-tickets.js +125 -0
- package/cli/job-line-turns.js +131 -0
- package/cli/job-line-wiring-helpers.js +75 -0
- package/cli/job-line.js +218 -0
- package/cli/literal-replace.js +35 -2
- package/cli/mcp/antigravity.js +2 -1
- package/cli/mcp/help.js +2 -2
- package/cli/mcp/manifests.js +4 -0
- package/cli/mcp/tools-q.js +40 -2
- package/cli/mcp/tools-read.js +5 -0
- package/cli/mcp/tools-verify.js +47 -5
- package/cli/project-detector.js +11 -21
- package/cli/reader-cli.js +3 -0
- package/cli/reader.js +4 -6
- package/cli/scaffold-frameworks.js +25 -8
- package/cli/search-args.js +12 -6
- package/cli/search-ast.js +3 -2
- package/cli/search-commands-literal.js +56 -4
- package/cli/search-literal.js +59 -7
- package/cli/search.js +9 -1
- package/cli/slot-files.js +103 -0
- package/cli/symbol-locator.js +4 -3
- package/cli/test-audit.js +11 -8
- package/cli/test-command.js +20 -3
- package/cli/test-output.js +11 -0
- package/cli/test-report.js +20 -6
- package/cli/test-run.js +54 -18
- package/cli/test-slots.js +60 -97
- package/cli/test-workers.js +24 -4
- package/cli/typecheck-audit.js +19 -5
- package/cli/verify-changed.js +7 -0
- package/cli/verify-report.js +7 -1
- package/cli/verify-steps.js +7 -3
- package/cli/verify.js +15 -8
- 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" -
|
|
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,
|
|
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,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,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
|
|
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-
|
|
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,
|
|
4
|
-
*
|
|
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
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
68
|
-
if (
|
|
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 =
|
|
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',
|
package/cli/audit/rules.js
CHANGED
|
@@ -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
|
-
*
|
|
22
|
-
* (git mv plus edits) is compared with
|
|
23
|
-
* past
|
|
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
|
|
26
|
-
const fields =
|
|
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
|
|
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
|
|
45
|
-
const
|
|
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
|