Skip to content

Commit 85debbc

Browse files
committed
Fixed recursive layout inheritance detection in v6
1 parent 2fc44f3 commit 85debbc

10 files changed

Lines changed: 447 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
GScan is a Ghost theme validation tool that checks themes for compatibility with different Ghost versions (v1-v6). It validates theme structure, Handlebars templates, package.json, assets, and Ghost-specific features.
8+
9+
## Essential Commands
10+
11+
### Testing
12+
```bash
13+
# Run all tests with coverage
14+
yarn test
15+
16+
# Run specific test file
17+
NODE_ENV=testing mocha test/010-package-json.test.js
18+
19+
# Run specific test pattern
20+
NODE_ENV=testing mocha test/*.test.js
21+
22+
# Debug mode testing
23+
NODE_ENV=testing DEBUG=gscan:* mocha test/checker.test.js
24+
```
25+
26+
### Development
27+
```bash
28+
# Dev server with debug output (port 2369)
29+
yarn dev # Runs: NODE_ENV=development DEBUG=gscan:* nodemon
30+
31+
# Production server
32+
yarn start # Runs: node app/index.js
33+
34+
# Lint code
35+
yarn lint
36+
yarn lint --fix
37+
38+
# Full release process (core team)
39+
yarn ship
40+
```
41+
42+
### CLI Usage
43+
```bash
44+
# Check theme directory
45+
./bin/cli.js /path/to/theme
46+
47+
# Check zip file
48+
./bin/cli.js -z /path/to/theme.zip
49+
50+
# Check for specific Ghost version
51+
./bin/cli.js /path/to/theme --v1 # Ghost 1.x
52+
./bin/cli.js /path/to/theme --v5 # Ghost 5.x
53+
./bin/cli.js /path/to/theme --canary # Latest/canary
54+
```
55+
56+
## Key Architecture Components
57+
58+
### Entry Points
59+
- **lib/index.js**: Main library exports (`check`, `checkZip`, `format`)
60+
- **bin/cli.js**: CLI tool entry point
61+
- **app/index.js**: Web server for https://gscan.ghost.org
62+
63+
### Core Validation Flow
64+
1. **lib/checker.js**: Orchestrates all checks
65+
- Loads theme via `lib/read-theme.js` (directories) or `lib/read-zip.js` (zips)
66+
- Executes all checks from `lib/checks/` sequentially
67+
- Returns theme object with `results.pass/fail` arrays
68+
69+
2. **lib/checks/**: Individual validation modules
70+
- Each exports async function: `(theme, options, themePath) => Promise`
71+
- Populates `theme.results.pass` or `theme.results.fail[code]`
72+
- Uses error codes like `GS010-PJ-NAME-REQ` (structured as GSXXX-CATEGORY-DETAIL)
73+
74+
3. **lib/ast-linter/**: Handlebars template analysis
75+
- Parses `.hbs` files into AST
76+
- Runs rules to detect issues (deprecated helpers, unknown globals, etc.)
77+
- Marks used features for compatibility checking
78+
79+
### Error Code Structure
80+
Error codes follow pattern: `GSXXX-YY-ZZZ`
81+
- `GSXXX`: Check number (e.g., GS010 for package.json)
82+
- `YY`: Category (e.g., PJ for package.json, ASSET for assets)
83+
- `ZZZ`: Specific rule (e.g., NAME-REQ for name required)
84+
85+
### Version Specifications
86+
Located in `lib/specs/v1.js` through `v6.js`:
87+
- `knownHelpers`: Valid Handlebars helpers
88+
- `knownGlobals`: Valid global variables
89+
- `rules`: Error definitions with levels (error/warning/recommendation)
90+
91+
## Testing Strategy
92+
93+
### Test Structure
94+
- Tests in `test/*.test.js` map to checks in `lib/checks/*.js`
95+
- Fixtures in `test/fixtures/themes/` contain example themes
96+
- Each fixture directory corresponds to a check number
97+
98+
### Test Utilities
99+
```javascript
100+
// test/utils.js provides helpers
101+
themePath('030-assets/valid') // Returns fixture path
102+
testCheck(theme, checkName, expectedFailCodes) // Validates check results
103+
```
104+
105+
### Common Test Patterns
106+
```javascript
107+
// Testing a passing theme
108+
const theme = await check(themePath('valid-theme'));
109+
theme.results.pass.should.containEql('GS010-PJ-REQ');
110+
111+
// Testing failures
112+
const theme = await check(themePath('invalid-theme'));
113+
theme.results.fail['GS010-PJ-NAME-REQ'].should.exist();
114+
```
115+
116+
## Debugging
117+
118+
Enable debug output with `DEBUG=gscan:*` environment variable:
119+
- `gscan:checker`: Main checker flow
120+
- `gscan:ast-linter`: Template parsing
121+
- Individual checks have their own namespaces
122+
123+
## Key Implementation Details
124+
125+
### Theme Object Structure
126+
```javascript
127+
{
128+
name: 'theme-name',
129+
path: '/path/to/theme',
130+
files: [{file: 'index.hbs', content: '...'}],
131+
partials: [],
132+
helpers: {},
133+
results: {
134+
pass: ['GS001-RULE1', 'GS002-RULE2'],
135+
fail: {
136+
'GS010-PJ-NAME-REQ': {
137+
failures: [{ref: 'package.json'}],
138+
rule: 'Package.json name is required'
139+
}
140+
}
141+
}
142+
}
143+
```
144+
145+
### Custom Theme Settings
146+
Themes can define custom settings in package.json:
147+
- Max 20 settings allowed
148+
- Types: `select`, `boolean`, `color`, `image`, `text`
149+
- Must use snake_case naming
150+
- Visibility conditions supported in v5+
151+
152+
### Output Formatting
153+
`lib/format.js` transforms results for different outputs:
154+
- CLI: Converts HTML tags to terminal colors
155+
- Web: Maintains HTML formatting
156+
- Calculates compatibility scores via `lib/utils/score-calculator.js`
157+
158+
## Adding New Rules/Checks
159+
160+
### Quick Guide for Adding a New Check
161+
162+
1. **Create the check file** in `lib/checks/XXX-check-name.js`:
163+
```javascript
164+
const checkSomething = function (theme, options) {
165+
// Your check logic
166+
if (failed) {
167+
theme.results.fail['GSXXX-ERROR-CODE'] = {
168+
failures: [{ref: 'file.hbs', message: 'Error message'}],
169+
fatal: true // if it's a critical error
170+
};
171+
} else {
172+
theme.results.pass.push('GSXXX-ERROR-CODE');
173+
}
174+
return theme;
175+
};
176+
module.exports = checkSomething;
177+
```
178+
179+
2. **Add the rule definition** to ALL version specs that need it (`lib/specs/v1.js` through `v6.js`):
180+
```javascript
181+
'GSXXX-ERROR-CODE': {
182+
level: 'error', // or 'warning', 'recommendation'
183+
fatal: true, // optional, for critical errors
184+
rule: 'Short description',
185+
details: oneLineTrim`Detailed explanation with <code>examples</code>`
186+
}
187+
```
188+
189+
3. **Create test fixtures** in `test/fixtures/themes/XXX-check-name/`:
190+
- Create subdirectories for different test scenarios
191+
- Each should have a minimal theme structure with `package.json` and relevant files
192+
193+
4. **Write tests** in `test/XXX-check-name.test.js`:
194+
```javascript
195+
const checkSomething = require('../lib/checks/XXX-check-name');
196+
describe('XXX Check name', function () {
197+
it('should detect the issue', function (done) {
198+
utils.testCheck(checkSomething, 'XXX-check-name/failing-case').then((output) => {
199+
output.results.fail['GSXXX-ERROR-CODE'].should.exist();
200+
done();
201+
});
202+
});
203+
});
204+
```
205+
206+
5. **Update checker test expectations** in `test/checker.test.js`:
207+
- Add 1 to the `lengthOf()` count for each version's pass array
208+
- Add the new rule code to the exact position in the pass arrays (they're alphabetically sorted)
209+
- Run tests to see where the rule should be positioned
210+
211+
### Important Notes When Adding Rules
212+
213+
- **Check files are auto-loaded**: Any `.js` file in `lib/checks/` is automatically executed by the checker
214+
- **Rule codes are alphabetically sorted** in test expectations
215+
- **All versions need the rule definition** even if the check runs for all versions - otherwise `lib/format.js` will fail with "Cannot read properties of undefined (reading 'level')"
216+
- **Use require('common-tags/lib/oneLineTrim')** for multi-line rule details in specs
217+
- **AST-based checks** should go in `lib/ast-linter/rules/` and be called from a check file
218+
- **The check function signature** is always `(theme, options, themePath) => Promise<theme>` where options may be unused
219+
220+
### Common Gotchas
221+
222+
1. **Missing rule in specs**: If you get `Cannot read properties of undefined (reading 'level')` errors, the rule definition is missing from one or more version specs
223+
2. **Test array counts**: The checker tests have exact counts of pass results - you must update these when adding new rules
224+
3. **Alphabetical ordering**: The pass results are sorted, so GSXXX codes must be inserted in the correct position in test arrays
225+
4. **Partial path normalization**: Use `normalizePath()` from `lib/utils` to handle Windows vs Unix path separators
226+
5. **Check options parameter**: Even if unused, keep the `options` parameter in the function signature and add `// eslint-disable-line no-unused-vars`
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
const _ = require('lodash');
2+
const path = require('path');
3+
const spec = require('../specs');
4+
const {versions, normalizePath} = require('../utils');
5+
6+
const ruleCode = 'GS130-NO-RECURSIVE-LAYOUT';
7+
const layoutPattern = /{{!<\s+([A-Za-z0-9._/-]+)\s*}}/g;
8+
9+
function stripLeadingSlash(filePath) {
10+
return filePath.replace(/^\/+/, '');
11+
}
12+
13+
function ensureExtension(layoutPath) {
14+
if (path.posix.extname(layoutPath)) {
15+
return layoutPath;
16+
}
17+
18+
return `${layoutPath}.hbs`;
19+
}
20+
21+
function resolveLayoutPath(sourceFile, layoutName) {
22+
const source = normalizePath(sourceFile);
23+
const layout = ensureExtension(normalizePath(layoutName));
24+
25+
if (layout.startsWith('./') || layout.startsWith('../')) {
26+
return stripLeadingSlash(path.posix.normalize(path.posix.join(path.posix.dirname(source), layout)));
27+
}
28+
29+
return stripLeadingSlash(path.posix.normalize(layout));
30+
}
31+
32+
function getLocation(source, index) {
33+
const lines = source.slice(0, index).split('\n');
34+
35+
return {
36+
line: lines.length,
37+
column: lines[lines.length - 1].length
38+
};
39+
}
40+
41+
function getLayoutReferences(themeFile) {
42+
const source = normalizePath(themeFile.normalizedFile || themeFile.file);
43+
const content = themeFile.content || '';
44+
const references = [];
45+
let match;
46+
47+
layoutPattern.lastIndex = 0;
48+
49+
while ((match = layoutPattern.exec(content)) !== null) {
50+
references.push({
51+
source,
52+
target: resolveLayoutPath(source, match[1]),
53+
location: getLocation(content, match.index)
54+
});
55+
}
56+
57+
return references;
58+
}
59+
60+
function buildInheritanceGraph(theme) {
61+
const hbsFiles = theme.files.filter(file => file.ext === '.hbs');
62+
const existingFiles = new Set(hbsFiles.map(file => normalizePath(file.normalizedFile || file.file)));
63+
const graph = new Map();
64+
65+
hbsFiles.forEach((themeFile) => {
66+
getLayoutReferences(themeFile).forEach((reference) => {
67+
if (!existingFiles.has(reference.target)) {
68+
return;
69+
}
70+
71+
if (!graph.has(reference.source)) {
72+
graph.set(reference.source, []);
73+
}
74+
75+
graph.get(reference.source).push(reference);
76+
});
77+
});
78+
79+
return graph;
80+
}
81+
82+
function getCyclePath(stack, target) {
83+
const targetIndex = stack.indexOf(target);
84+
return stack.slice(targetIndex).concat(target).join(' -> ');
85+
}
86+
87+
function getRecursiveLayoutFailures(graph) {
88+
const visited = new Set();
89+
const visiting = new Set();
90+
const stack = [];
91+
const reportedCycles = new Set();
92+
const failures = [];
93+
94+
function visit(file) {
95+
visiting.add(file);
96+
stack.push(file);
97+
98+
const references = graph.get(file) || [];
99+
100+
references.forEach((reference) => {
101+
if (visiting.has(reference.target)) {
102+
const cyclePath = getCyclePath(stack, reference.target);
103+
104+
if (!reportedCycles.has(cyclePath)) {
105+
reportedCycles.add(cyclePath);
106+
107+
failures.push({
108+
ref: reference.source,
109+
line: reference.location.line,
110+
column: reference.location.column,
111+
message: `Recursive layout inheritance detected: ${cyclePath}`
112+
});
113+
}
114+
115+
return;
116+
}
117+
118+
if (!visited.has(reference.target)) {
119+
visit(reference.target);
120+
}
121+
});
122+
123+
stack.pop();
124+
visiting.delete(file);
125+
visited.add(file);
126+
}
127+
128+
graph.forEach((references, file) => { // eslint-disable-line no-unused-vars
129+
if (!visited.has(file)) {
130+
visit(file);
131+
}
132+
});
133+
134+
return failures;
135+
}
136+
137+
const checkTemplateInheritance = function checkTemplateInheritance(theme, options) {
138+
const checkVersion = _.get(options, 'checkVersion', versions.default);
139+
const ruleSet = spec.get([checkVersion]);
140+
141+
if (!ruleSet.rules[ruleCode]) {
142+
return theme;
143+
}
144+
145+
const failures = getRecursiveLayoutFailures(buildInheritanceGraph(theme));
146+
147+
if (failures.length > 0) {
148+
theme.results.fail[ruleCode] = {failures};
149+
} else {
150+
theme.results.pass.push(ruleCode);
151+
}
152+
153+
return theme;
154+
};
155+
156+
module.exports = checkTemplateInheritance;

lib/specs/v6.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ let rules = {
4848
details: 'AMP support was removed in Ghost 6.0. Remove AMP templates and use responsive design instead.',
4949
// Matches <html amp> or <html ⚡>, with or without other attributes mixed in
5050
regex: /<html\s+(?:amp|)(?:\s|>)|<html\s+[^>]*\s(?:amp|)(?:\s|>)/i
51+
},
52+
'GS130-NO-RECURSIVE-LAYOUT': {
53+
level: 'error',
54+
fatal: true,
55+
rule: 'Templates must not recursively inherit layouts',
56+
details: oneLineTrim`Remove recursive layout inheritance such as <code>{{!&lt; default}}</code> from <code>default.hbs</code>. A template cannot inherit from itself, directly or through another layout, because it can cause rendering to recurse indefinitely.`
5157
}
5258
};
5359

0 commit comments

Comments
 (0)