Repository navigation
Engineering Wiki Scanner Developer Guide
How System -> Engineering wiki is built: the scanner that reads FreeITSM's own PHP and JavaScript and writes the Code map, the Markdown renderer that shows it, and the viewer around them. To write comments the scanner understands, see Commenting for the Engineering wiki.
In one paragraph: a PHP class, EwScanner, walks the app, reads every .php file with PHP's own tokenizer (and every .js file line by line), and records what it finds - files, functions, parameters, classes, includes, calls, SQL, traps, request fields and access checks. It keeps that in a manifest so the next scan only re-reads files that changed. From the manifest it writes one Markdown page per file, function, class and database table, plus indexes, under engineering-wiki/code/. The viewer reads those pages through an administrator-only API and renders them with a small Markdown renderer written for the purpose. No AI, no third-party library, no database tables.
π’ new in 3.6.0 Β· π΅ changed
| File | What it is |
|---|---|
π’ includes/engineering_wiki/scanner.php
|
EwScanner - list, parse, write. About 1,600 lines; everything in this guide except the viewer lives here |
π’ includes/engineering_wiki/store.php
|
Where pages live: ewSafePath(), ewFile(), ewResolveLink(), ewReadPage(), ewListDir(), ewSearch()
|
π’ includes/engineering_wiki/markdown.php
|
EwMarkdown - the GitHub-flavoured renderer. Escapes all HTML |
π’ api/system/engineering-wiki/page.php
|
GET ?page= - one page, rendered |
π’ api/system/engineering-wiki/tree.php
|
GET ?dir= - one folder of the sidebar tree |
π’ api/system/engineering-wiki/search.php
|
GET ?q= - search every page |
π’ api/system/engineering-wiki/scan.php
|
POST - run ~6 seconds of scanning (the Rescan button) |
π’ scripts/engineering-wiki-scan.php
|
The same scan from the command line, to the end. --full forgets the last scan |
π’ system/engineering-wiki/index.php
|
The page: sidebar, tabs, split view |
π’ assets/js/engineering-wiki.js Β· assets/css/engineering-wiki.css
|
The viewer's behaviour and the GitHub look, light and dark |
π’ engineering-wiki/handbook/*.md
|
Pages written by people - start, comments, standard parameters. Never touched by the scanner |
π’ engineering-wiki/code/**.md
|
Pages written by the scanner. Shipped with each release, rebuilt by a Rescan |
π’ engineering-wiki/code/_scan/
|
The scanner's state: manifest.json, state.json, pages.json, last.json, lock. Git-ignored |
π’ engineering-wiki/.htaccess Β· web.config
|
Deny all - the .md files are never served directly |
π΅ includes/db_verify_schema.php
|
Not new, but the scanner reads it for every table and column, comments included |
There are no database tables. Everything is files.
A scan is three phases, run in steps under a time budget so no web request runs long:
list -> walk the app; queue every file that is new or changed; forget deleted ones
parse -> read each queued file; store what it found in the manifest
write -> build every page from the manifest; write only those that changed
step() does as much as it can in the time it is given, saves where it got to, and returns. The Rescan button calls the API in a loop, 6 seconds a time, until done is true. The command line calls runAll(), which steps 20 seconds at a time to the end.
public function step(float $seconds = 8.0, bool $fresh = false): array
{
$this->deadline = microtime(true) + $seconds;
$this->ensureDirs();
$lock = fopen($this->stateDir . '/lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
return $this->report('busy', 'Another scan is running.');
}
try {
$this->loadManifest();
$this->state = $fresh ? [] : $this->readJson('state.json', []);
if (empty($this->state['phase'])) $this->state = ['phase' => 'list', 'queue' => [], ...];
if ($this->state['phase'] === 'list') $this->phaseList();
if ($this->state['phase'] === 'parse') $this->phaseParse();
if ($this->state['phase'] === 'write' && microtime(true) < $this->deadline + 30) $this->phaseWrite();
$this->saveState();
return $this->report($this->state['phase'], '');
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}Plain English:
-
One scan at a time. A non-blocking file lock. A second Rescan, or the command line while the browser is scanning, gets
busyback instead of two scans tearing up the same files. -
Resumable. The queue lives in
state.jsonand is saved every 3 seconds during parsing. Close the tab half way and the next Rescan carries on from the next file. -
The write phase is not split. It needs every file's parse at once, so once started it runs to the end (it has 30 seconds of grace past the deadline, and
set_time_limit(300)). On the full codebase it takes a few seconds.
$this->walk($this->app, '', function (string $rel, string $abs) use (&$seen, &$queue) {
$seen[$rel] = true;
$mt = filemtime($abs); $sz = filesize($abs);
$old = $this->manifest['files'][$rel] ?? null;
if ($old && $old['mtime'] === $mt && $old['size'] === $sz) return;
if ($old && $old['size'] === $sz && $old['sha1'] === sha1_file($abs)) { // touched, not changed
$this->manifest['files'][$rel]['mtime'] = $mt;
return;
}
$queue[] = $rel;
});Three checks, cheapest first: same modified time and size - skip, without reading the file. Same size but a new time - read it once for the SHA-1, and if the content is identical just note the new time (a git checkout touches every file without changing them). Anything else is queued.
walk() takes .php and .js files and skips:
- the folders in
SKIP_DIRS-.git,vendor,node_modules,engineering-wiki,lang,tests,local,docs,database,uploads,recordings,logs,data,cache,tmp,releases,samples,system-wiki,tinymce - hidden folders (starting with a dot)
- any folder whose
.htaccessholdsRequire all deniedorDeny from all- that is how upload folders are marked, so a new one is skipped without anyone remembering to add it -
*.min.js, and any file over 1.5 MB (bundled or generated, not ours to document)
while ($this->state['queue']) {
if (microtime(true) > $this->deadline) break;
$rel = array_shift($this->state['queue']);
$src = (string)file_get_contents($this->app . '/' . $rel);
$data = substr($rel, -3) === '.js' ? $this->parseJs($src, $rel) : $this->parsePhp($src, $rel);
$this->manifest['files'][$rel] = ['mtime' => filemtime($abs), 'size' => filesize($abs), 'sha1' => sha1($src), 'data' => $data];
...
}The parse is in sections 4 and 5. It runs on files one at a time and needs nothing from any other file - linking things together waits for phase 3.
Phase 3 loads every file's parse from the manifest and builds indexes across them:
| Index | Built from | Used for |
|---|---|---|
$fnIndex - function name -> where defined |
every functions[] with no class |
function pages, links from calls |
$classIndex - class -> file, doc, methods |
classes[] and functions[] with a class |
class pages |
$callers - function -> who calls it |
every calls[], kept only when the callee is one of ours |
Called by |
$includedBy - file -> files that include it |
includes[] |
Included by, and the guard search |
$endpointCallers - endpoint -> JS that calls it |
each JS file's endpoints[]
|
Called from on API pages |
$scriptUsers - JS file -> pages that load it |
<script src> found in PHP's inline HTML |
Loaded by |
$tableUse - table -> readers and writers |
every sql[]
|
Read by / Written by on table pages |
Then it builds every page as a string, and writes only the pages whose content differs from what is on disk:
foreach ($pages as $page => $md) {
$path = ewRoot() . '/' . $page . '.md';
if (is_file($path) && file_get_contents($path) === $md) continue;
if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
file_put_contents($path, $md);
$written++;
}
$previous = $this->readJson('pages.json', []);
foreach ($previous as $page) {
if (!isset($pages[$page])) { @unlink(ewRoot() . '/' . $page . '.md'); }
}
$this->writeJson('pages.json', array_keys($pages));pages.json is the list of pages the last scan wrote, so a page for a deleted function or file is removed - and only pages the scanner made, never a handbook page. Empty folders are tidied afterwards.
Writing only what changed matters for git: a one-line comment change produces a diff of the two or three pages it touches, not 4,800.
engineering-wiki/code/_scan/manifest.json holds one entry per file. A real entry, trimmed:
{
"version": 10,
"files": {
"includes/ticket_snooze.php": {
"mtime": 1791932701,
"size": 14517,
"sha1": "43bd35e52c15700787134fbe0bd2f5bd446bc1da",
"data": {
"kind": "php",
"doc": {
"summary": "Snoozing a ticket - \"I can't do anything with this until Thursday.\"",
"body": "WHY THIS EXISTS\n---------------\nHalf of a service desk's que...",
"tags": []
},
"functions": [
{
"name": "snoozeSchemaReady", "class": null, "line": 56,
"params": [
{"type": "PDO", "name": "$conn", "default": "", "ref": false, "variadic": false, "note": ""}
],
"return": "bool",
"doc": {"summary": "Does this database have the snooze columns yet?", "body": "The inbox list and its folder counts are ...", "tags": []},
"static": false, "visibility": ""
}
],
"classes": [],
"includes": ["includes/functions.php", "includes/timezone.php"],
"calls": [["snoozeSchemaReady", "snoozeHiddenSql", 124], ["snoozeSchemaReady", "snoozeOnlySql", 137]],
"sql": [
{"op": "SHOW", "reads": ["tickets"], "writes": [], "line": 60, "fn": "snoozeSchemaReady", "note": null}
],
"traps": [],
"inputs": [],
"guards": [],
"scripts": [],
"session": [],
"lines": 327
}
}
}
}| Key | Holds |
|---|---|
mtime, size, sha1
|
The fingerprint phase 1 compares against |
doc |
The file comment: summary (first sentence-paragraph), body (the rest), tags ([name, value] pairs) |
functions |
One per declaration - class methods too, with class set |
calls |
[callee, caller, line], each callee/caller pair once. caller is null at the top level |
sql |
One per SQL string - see section 6 |
inputs |
GET:page, POST:id... - the request fields read with a literal key |
guards |
[function, first argument, inside function] for every require*() check |
session |
The $_SESSION keys read |
scripts |
<script src> files the page loads, from the app root |
version is the class constant VERSION. Bump it whenever the shape of the parse changes - a manifest from another version is thrown away and every file read again:
private function loadManifest(): void
{
$m = $this->readJson('manifest.json', null);
if (is_array($m) && ($m['version'] ?? 0) === self::VERSION) $this->manifest = $m;
}Important
The manifest is never shipped. Modified times differ per checkout, so a shipped manifest would be wrong on every install. A fresh install's first Rescan reads everything once (around a minute); after that the SHA-1 check stops a mere change of time counting as a change.
The state files are written through a temporary file and a rename, so a request that reads one while a scan is writing never sees half a file:
file_put_contents($f . '.tmp', json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
@rename($f . '.tmp', $f) || (@unlink($f) && @rename($f . '.tmp', $f)); // Windows will not rename over a fileparsePhp() makes one pass over token_get_all($src). The tokenizer is PHP's own, so strings, comments, heredocs and ?> HTML are never confused with code - something a regex reading the source could not promise.
It keeps a little state as it walks:
| Variable | Tracks |
|---|---|
$depth |
Brace depth - every {, ${ and {$ up, every } down |
$class, $classDepth
|
The class being read, and the depth it ends at |
$fnStack |
[name, depth] for each function body being read - so a call or query knows which function it is in |
$pendingDoc |
The last /** */ block, waiting for the declaration it describes |
$lineRun |
The // lines (or /* */ block) directly above what comes next |
$sawCode |
Has any real code been seen yet? Decides what the file comment is |
A helper finds the next or previous significant token - skipping whitespace and comments - which most of the checks lean on:
$sig = function (int $i, int $dir) use ($tokens, $n) {
for ($k = $i + $dir; $k >= 0 && $k < $n; $k += $dir) {
$t = $tokens[$k];
if (is_array($t) && in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) continue;
return $k;
}
return -1;
};if ($tid === T_DOC_COMMENT) {
if (!$sawCode && $d['doc'] === null) {
// TRAP: the first doc block in a file is ALWAYS the file's description. ...
$d['doc'] = $this->parseDoc($t[1]);
$k = $i + 1;
while ($k < $n && $id($tokens[$k]) === T_WHITESPACE) $k++;
$nt = $k < $n ? $id($tokens[$k]) : null;
if (in_array($nt, [T_FUNCTION, T_CLASS, T_INTERFACE, T_TRAIT, T_ABSTRACT, T_FINAL, T_READONLY], true)) $pendingDoc = $t[1];
continue;
}
$pendingDoc = $t[1];
}The first /** */ before any code is the file's. If a declaration follows it with only whitespace between, it is also offered to that declaration - the common "one header, then the class" shape. If there is no /** */ header, the // lines or /* */ block at the top of the file are used instead (lineDoc()), once code starts.
A declaration takes $pendingDoc if there is one, else the $lineRun above it. Both are cleared by:
- a blank line (whitespace holding two or more newlines) - for
$lineRun - any token that is not a modifier -
public,private,protected,static,abstract,final,readonlyor an attribute keep the comment alive, so a doc block abovepublic static functionstill reaches the function
That is why a stray statement or an unrelated comment between a doc block and its function loses the description.
if ($tid === T_FUNCTION) {
$nx = $sig($i, 1);
if ($nx >= 0 && $id($tokens[$nx]) === '&') $nx = $sig($nx, 1);
if ($nx < 0 || $id($tokens[$nx]) !== T_STRING) { $pendingDoc = null; continue; } // a closure
$name = $tokens[$nx][1];
[$params, $close] = $this->params($tokens, $sig($nx, 1));
...
$d['functions'][] = ['name' => $name, 'class' => $class, 'line' => $line, 'params' => $params, 'return' => trim($ret),
'doc' => $pendingDoc ? $this->parseDoc($pendingDoc) : ($lineRun ? $this->lineDoc($lineRun) : null), ...];
// find its body, and push it on $fnStack so what follows knows where it is
}function followed by a name is a declaration; followed by ( it is a closure and skipped. params() walks from ( to the matching ), splitting on commas at depth 1 only (so a default of [1, 2] is one parameter), and collects each one's type, &, ..., name and default. Constructor-promoted properties (public int $x) have their visibility dropped. A // comment on a parameter's line becomes its note - after the comma it belongs to the parameter just finished, before it to the current one.
Classes, interfaces and traits are found the same way, ignoring Foo::class and new class.
| Seen | Recorded |
|---|---|
name( - not after ->, ?->, new, function or ::
|
calls[] - a plain function call |
Class::name( |
calls[] as Class::name
|
require / include (_once too) |
includes[], if the path can be worked out - see below |
$_GET['x'], $_POST['x'], $_REQUEST['x'] with a literal key |
inputs[] as GET:x
|
$_SESSION['x'] |
session[] |
A call to require*Admin*, require*Access*, require*Cap* ... |
guards[], with its first argument and the function it is in |
<script src="...js"> in inline HTML |
scripts[] |
Include paths are worked out from the tokens, not guessed. String pieces, __DIR__ and dirname(__DIR__, n) are understood; anything with a variable or another function is left out rather than recorded wrong:
require_once __DIR__ . '/../includes/leases.php'; // -> includes/leases.php
require_once dirname(__DIR__, 2) . '/config.php'; // -> config.php
require_once '../../../includes/admin_api_guard.php'; // -> includes/admin_api_guard.php (from the file's folder)
require_once $base . '/x.php'; // -> (not recorded)private function parseDoc(string $raw): array
{
$raw = preg_replace(['#^/\*\*+#', '#\*+/$#'], '', $raw);
// A one-line comment can carry its tags on the same line ("/** Summary. @return int */").
if (strpos(trim($raw), "\n") === false) {
$raw = preg_replace('/(\S)\s+(@(?:param|return|reads|writes|see|wiki|input|output|throws)\b)/', "\$1\n\$2", $raw);
}
$lines = array_map(fn($l) => preg_replace('/^\s*\*( ?)/', '', rtrim($l)), explode("\n", $raw));
foreach ($lines as $l) {
if (preg_match('/^\s*@(\w+)\s*(.*)$/', $l, $m)) { $tags[] = [strtolower($m[1]), trim($m[2])]; continue; }
if ($tags && trim($l) !== '' && preg_match('/^\s{2,}/', $l)) { $tags[count($tags) - 1][1] .= ' ' . trim($l); continue; } // a tag running on
if ($tags && trim($l) === '') continue;
$text[] = $l;
}
...
$summary = first paragraph, on one line; $body = the rest
}- The summary is the first paragraph, squashed onto one line. The body is everything after the first blank line.
- A line starting
@wordis a tag. An indented line after a tag continues it. - Tags are only split out of the middle of a line in a one-line comment, so prose such as "add @param lines" in a longer comment is never mistaken for a tag.
lineDoc() turns a run of // lines into the same shape by building a doc comment from them. It drops decorative rules and banners - // ----, // ====, /* βββ KNOWLEDGE βββ */ - and returns nothing when the run is only a TRAP:, because a warning is not a description.
Any comment holding TRAP: is a trap. A // trap takes the // lines straight after it as well, so a three-line warning is kept whole. trapText() strips the comment markers, keeps the text from TRAP: to the end of its paragraph and puts it on one line. Each trap records its line and the function it is in.
PHP has a tokenizer; JavaScript in PHP does not, so parseJs() reads JavaScript line by line. That is deliberately less ambitious: it finds functions and the endpoints they call, not calls between functions.
Each trimmed line is tested against the shapes the codebase uses, in order:
// function name(a) export / async / generator allowed
'/^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(([^)]*)\)?/'
// name = function (a) const name = function window.name = function this.name = function
'/^(?:(?:const|let|var)\s+)?((?:window|this|self|[A-Za-z_$][\w$]*)\.)?([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function\b[^(]*\(([^)]*)\)?/'
// const name = (a) => const name = a =>
'/^(?:(?:const|let|var)\s+)?((?:window|this|self)\.)?([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\(([^)]*)\)|([A-Za-z_$][\w$]*))\s*=>/'
// name: function (a) in an object literal
'/^([A-Za-z_$][\w$]*)\s*:\s*(?:async\s+)?function\s*\(([^)]*)\)?/'
// name(a) { a class method - but not if (, for (, while (... and not a call taking a callback
'/^(?:static\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{\s*$/'The comment above a function is whatever comment the previous lines held: a /* */ block, or a run of // lines. A blank line or any other line clears it.
Endpoints are any string that holds api/.../x.php, plus the common shape where an api/ folder is held in a constant:
const API_BASE = '../api/tickets/';
fetch(API_BASE + 'save.php', ...) // -> api/tickets/save.phpif (preg_match_all('#(?:const|let|var)\s+([A-Z_][A-Z0-9_]*)\s*=\s*[\'"]([^\'"]*api/[^\'"]*)[\'"]#', $src, $bm)) ...
if (preg_match_all('#[\'"`]([^\'"`\s]*api/[\w/.-]+\.php)#', $src, $em)) ...Leading ../ are dropped, and the path counts only if it is a file the scan actually found.
Every string literal - single-quoted, double-quoted with {$interpolation} joined back together, or a heredoc - goes through sqlOf(). A string is SQL when it starts with a SQL verb, or contains FROM table / JOIN table:
if (!preg_match('/^\s*\(?\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|SHOW|WITH)\b/i', $s, $m)
&& !preg_match('/\b(FROM|JOIN)\s+`?[a-z_][a-z0-9_]*`?\s/i', $s)) return null;The second test catches a query built in pieces, where the string holding FROM is not the one holding SELECT.
Reads are every table after FROM or JOIN. Writes depend on the verb:
| Statement | Recorded as |
|---|---|
INSERT INTO t (a, b) VALUES ... |
insert t (a, b) |
INSERT INTO t SET ... / INSERT ... SELECT
|
insert t
|
UPDATE t x JOIN ... SET x.a = ?, b = NOW() WHERE ... |
update t (a, b) - the columns before each =, between SET and WHERE
|
DELETE FROM t ... / DELETE x FROM t x ...
|
delete t (and not a read of t) |
CREATE/ALTER/DROP/TRUNCATE TABLE t
|
Schema upkeep - shown on the file, but not counted as a use of the table's data |
For example:
UPDATE lease_assets la
JOIN lease_schedules ls ON ls.id = la.schedule_id
SET la.end_date = ls.end_date, la.updated_datetime = UTC_TIMESTAMP()
WHERE ls.id = ?is recorded as update lease_assets (end_date, updated_datetime) and a read of lease_schedules.
Only names that are real tables in includes/db_verify_schema.php are linked or counted, so a FROM in an English sentence, or a derived table alias, never invents a table.
The query's purpose comes from a // SQL: comment. Every one in the file is collected first by line, then a query looks for a note on its own line, or one or two lines above:
$note = $sqlNotes[$line] ?? $sqlNotes[$line - 1] ?? $sqlNotes[$line - 2] ?? null;What the scanner cannot see: a table name held in a variable ("SELECT * FROM $table"), and SQL built by a helper in another file. @reads / @writes on the function fill that gap by hand. When a function's tags name a table that none of its own SQL touches, its page shows a caution - either the comment is stale or the SQL is built elsewhere.
Table pages come from includes/db_verify_schema.php - the same array Database Verification uses to create tables, so it is always the truth about columns. The scanner requires it for the definitions, then tokenizes it a second time for the comments, which require cannot see. Comments written like this, for example:
// --- Leasing (3.5.0) ----------------------------------------
// An asset on a schedule; its own start/end when they differ from the schedule's.
'lease_assets' => [
'id' => 'INT NOT NULL AUTO_INCREMENT',
'outcome_id' => 'INT NULL', // lease_outcomes.id; NULL = still on lease
...
],A comment above a table is the table's note. A comment above a column, or after it on the same line, is the column's note. A // --- Heading --- banner resets what is pending, so a section heading is never mistaken for the next table's description.
For every file under api/, the page shows how it is protected. guardText() decides, in this order:
if (basename($rel)[0] === '_') return 'Helper - included by other endpoints, not called directly';
if (strpos($rel, 'api/v1/') === 0) return 'REST API key';
$g = $this->guardsOf($d, ''); // the file's own checks
// none? follow its includes, up to 3 hops, skipping config.php and includes/functions.php
...
if (in_array('analyst_id', $sess, true)) return 'Signed-in analyst (session check)';
if (in_array('ss_user_id', $sess, true)) return 'Signed-in portal user (session check)';
return 'Public - no sign-in check found (...)';guardsOf() turns what parsing found into words:
| Found | Shown as |
|---|---|
require_once '.../includes/admin_api_guard.php' |
Administrators only |
requireAdmin...() |
Administrators only |
requireModuleAccess('tickets') |
Module access: tickets |
requireCapability(Caps::ASSETS_EDIT) |
Permission: Caps::ASSETS_EDIT |
| a check found in an include | ... (via api/assets/_bootstrap.php) |
A check inside a function is ignored here - it guards whoever calls that function, not the file.
The following of includes is what makes shared bootstraps work: an endpoint whose only line of protection is require '_bootstrap.php' is shown with the bootstrap's check. When nothing is found the page says Public - correct for sign-in pages, webhooks and token links, and a prompt to look at the code for anything else. The Endpoints page lists every endpoint with its guard, so a missing check stands out.
Note
This is a reading aid, not a security control. It reports what the code appears to do; the code is the check.
| Page | Path | Holds |
|---|---|---|
| Home | code/index |
Counts and the way in |
| File | code/files/<path> |
Summary; for an API file the endpoint box (URL, guard, inputs, callers); functions and classes; Database; includes / included by / loaded by; traps |
| Function | code/functions/<name> |
Signature, Parameters, returns, reads/writes, SQL, callers, traps, @see, @wiki box |
| Class | code/classes/<name> |
Its doc and every method |
| Table | code/tables/<name> |
Columns with notes; Written by; Read by |
| Indexes |
code/functions/index etc. |
A-Z lists with summaries |
| Endpoints | code/endpoints |
Every API file, its guard and its callers |
| Traps | code/traps |
Every TRAP: in the codebase |
Pages link to each other with ordinary relative Markdown links from the wiki root, e.g. [leases](/code/tables/leases.md). Text from comments goes through prose(), which puts $variables, name() and @tags in code style so an underscore or asterisk in them does not turn into italics, and through cell() before going into a table, which escapes |.
A parameter's Meaning comes from, in order: its @param text, then a comment on its own line, then standardParam() - a short list of names that mean the same everywhere, each checked against its type so $conn only counts when it is a PDO:
case '$conn': if ($type === 'PDO' || $type === '') $m = 'The database connection.'; break;
case '$analystId': if ($type === 'int') $m = 'The analyst doing this - normally the one signed in.'; break;
case '$ctx': if ($type === 'ActorContext') $m = 'Who is acting, which companies they may reach, ui or api, and their language.'; break;
case '$apiKey': if ($api && $type === 'array') $m = 'The API key that authenticated the call - its scopes and company limit.'; break;
...
return $m === null ? '' : $m . ' *([standard](/handbook/standard-parameters.md))*';@wiki tags, and any github.com/edmozley/freeitsm/wiki/... link already written in a comment, become a box at the top of the page linking out to this wiki. Only the link - the GitHub wiki's text is never copied into FreeITSM.
All four API files include admin_api_guard.php - the whole wiki is administrators only, like the rest of System. A page path from a request goes through ewSafePath() and then ewFile():
function ewSafePath(string $p): ?string
{
$p = trim(str_replace('\\', '/', $p), '/');
$p = preg_replace('/\.md$/', '', $p);
if ($p === '' || strlen($p) > 300) return null;
if (!preg_match('#^[A-Za-z0-9_][A-Za-z0-9_./-]*$#', $p)) return null;
if (strpos($p, '..') !== false || strpos($p, '//') !== false) return null;
$section = explode('/', $p)[0];
if (!in_array($section, EW_SECTIONS, true)) return null; // handbook or code
return $p;
}A fixed character set, no .., one of the two sections - and then ewFile() resolves the real path and checks it still starts with the wiki folder, so a symlink cannot lead out either. The .md files themselves sit behind .htaccess / web.config deny-all rules and are never served directly.
Search reads every .md file and scores it: 10 per word found in the title or path, 1 per word in the body, a small bonus for the handbook; every word must match somewhere. With around 4,800 pages that is fast enough without an index.
EwMarkdown is about 500 lines and covers the GitHub-flavoured subset the pages use: headings with GitHub anchors, emphasis, code, links, [[wiki links]], quotes and alerts (> [!NOTE], TIP, IMPORTANT, WARNING, CAUTION), lists and task lists, tables with alignment, fenced code with light highlighting for PHP, SQL, JS, JSON and bash, and rules.
Warning
It never passes HTML through. Every character of text is escaped. Generated pages quote code comments, and a comment is not trusted input - if <script> in a comment reached the page as HTML, it would run in an administrator's browser. Anything that needs markup gets a Markdown form instead.
Links are handed to a resolver from the store. A link to a .md file becomes <a href="?page=..." data-ew-page="...">, which the viewer opens in a tab; anything with a scheme opens in a new browser window.
assets/js/engineering-wiki.js gives the page its browser feel:
- Tabs - click a link to open it in the current tab; Ctrl+click, middle-click or Open in new tab for another. Middle-click on a tab closes it. Each tab has its own back and forward history.
- Split view - two panes side by side, each with its own tabs.
- Right-click menu on links, tabs and the sidebar - Open, Open in new tab, Open beside (the other pane), Copy link.
-
Sidebar - the tree, loaded folder by folder from
tree.php, because the code map is thousands of pages. -
Rescan - calls
scan.phpin a loop with a progress bar until it reportsdone. - The address bar holds
?page=, so any page can be bookmarked or sent to a colleague.
Before a release, refresh the Code map from the command line so the shipped pages match the code:
php scripts/engineering-wiki-scan.phpdone 1/1 files
Done in 5.0s: 1783 files read, 1 changed, 4852 pages (10 written).
--full forgets the manifest and reads everything again - use it after changing the scanner itself if VERSION was not bumped. Commit the changed pages under engineering-wiki/code/ with the release. _scan/ is git-ignored.
On an install, the shipped pages are there from the start. A Rescan only matters when code on that install has changed.
| Trap | Why |
|---|---|
Bump VERSION when the parse changes shape |
Otherwise old manifest entries, in the old shape, are reused for every unchanged file |
Never ship _scan/
|
Modified times are per checkout - a shipped manifest is wrong everywhere |
| The first doc block is always the file's | It used to go to the next declaration, and files with a proper header showed no summary |
| The renderer escapes everything | Comments are quoted into pages; any HTML pass-through is script in an admin's browser |
Every path from a request goes through ewSafePath() then ewFile()
|
Same three checks, in the same order, everywhere |
walk() skips folders whose .htaccess denies all |
That is how upload folders are marked; documenting a user's uploaded .php would be worse than useless |
| Write only changed pages | A rescan after a one-line change must not touch 4,800 files in git |
/** */ inside a comment's text |
*/ ends the comment early - in PHP that is usually a parse error, not just a lost description |
-
A new kind of fact about a file - add it to
$dinparsePhp(), use it in a page builder, bumpVERSION. -
A new tag -
parseDoc()already keeps every@word; read it from$doc['tags']in the page builder. Add it to the one-liner split list if it may appear on one line. -
A new standard parameter - add a
casetostandardParam()with a type check, and a row toengineering-wiki/handbook/standard-parameters.md. -
A new guard function - if its name matches
require...Admin/Access/Cap...it is picked up already. Otherwise add it to the pattern inparsePhp()and toguardsOf(). -
A new page type - build its Markdown in a
...Page()method and$put()it inphaseWrite().pages.jsonwill take care of deleting it when it stops being produced.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96