@latysh-development/monolith 0.1.0-alpha.0 → 0.1.0-alpha.1
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/README.md +63 -16
- package/dist/brief/collect.d.ts.map +1 -1
- package/dist/brief/collect.js +41 -1
- package/dist/brief/constants.d.ts +2 -0
- package/dist/brief/constants.d.ts.map +1 -1
- package/dist/brief/constants.js +2 -0
- package/dist/brief/render.d.ts +7 -0
- package/dist/brief/render.d.ts.map +1 -1
- package/dist/brief/render.js +52 -1
- package/dist/brief/types.d.ts +17 -1
- package/dist/brief/types.d.ts.map +1 -1
- package/dist/cli/index.js +33 -0
- package/dist/discovery/compose.d.ts +28 -0
- package/dist/discovery/compose.d.ts.map +1 -0
- package/dist/discovery/compose.js +159 -0
- package/dist/discovery/contracts.d.ts +211 -0
- package/dist/discovery/contracts.d.ts.map +1 -0
- package/dist/discovery/contracts.js +555 -0
- package/dist/discovery/docs.d.ts +278 -0
- package/dist/discovery/docs.d.ts.map +1 -0
- package/dist/discovery/docs.js +1182 -0
- package/dist/discovery/facts.d.ts +49 -0
- package/dist/discovery/facts.d.ts.map +1 -0
- package/dist/discovery/facts.js +541 -0
- package/dist/discovery/index.d.ts +8 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +9 -0
- package/dist/discovery/orchestrate.d.ts +35 -0
- package/dist/discovery/orchestrate.d.ts.map +1 -0
- package/dist/discovery/orchestrate.js +32 -0
- package/dist/discovery/render.d.ts +36 -0
- package/dist/discovery/render.d.ts.map +1 -0
- package/dist/discovery/render.js +278 -0
- package/dist/discovery/traverse.d.ts +197 -0
- package/dist/discovery/traverse.d.ts.map +1 -0
- package/dist/discovery/traverse.js +510 -0
- package/dist/durable/errors.d.ts +6 -2
- package/dist/durable/errors.d.ts.map +1 -1
- package/dist/durable/errors.js +9 -3
- package/dist/durable/fs-store.d.ts +21 -0
- package/dist/durable/fs-store.d.ts.map +1 -1
- package/dist/durable/fs-store.js +103 -3
- package/dist/durable/prune-check.d.ts +17 -0
- package/dist/durable/prune-check.d.ts.map +1 -1
- package/dist/durable/prune-check.js +27 -0
- package/dist/durable/runtime.d.ts +5 -3
- package/dist/durable/runtime.d.ts.map +1 -1
- package/dist/durable/runtime.js +23 -6
- package/dist/durable/seams.d.ts +4 -0
- package/dist/durable/seams.d.ts.map +1 -1
- package/dist/durable/testing.js +2 -2
- package/dist/ideas/cli.d.ts +33 -0
- package/dist/ideas/cli.d.ts.map +1 -0
- package/dist/ideas/cli.js +158 -0
- package/dist/ideas/format.d.ts +38 -0
- package/dist/ideas/format.d.ts.map +1 -0
- package/dist/ideas/format.js +194 -0
- package/dist/ideas/index.d.ts +6 -0
- package/dist/ideas/index.d.ts.map +1 -0
- package/dist/ideas/index.js +3 -0
- package/dist/ideas/store.d.ts +55 -0
- package/dist/ideas/store.d.ts.map +1 -0
- package/dist/ideas/store.js +262 -0
- package/dist/ideas/types.d.ts +54 -0
- package/dist/ideas/types.d.ts.map +1 -0
- package/dist/ideas/types.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/init/scaffold.d.ts +4 -0
- package/dist/init/scaffold.d.ts.map +1 -1
- package/dist/init/scaffold.js +6 -2
- package/dist/managed/index.d.ts +73 -0
- package/dist/managed/index.d.ts.map +1 -0
- package/dist/managed/index.js +222 -0
- package/dist/root/index.d.ts +7 -0
- package/dist/root/index.d.ts.map +1 -1
- package/dist/root/index.js +13 -1
- package/dist/update/cli.d.ts +8 -0
- package/dist/update/cli.d.ts.map +1 -0
- package/dist/update/cli.js +115 -0
- package/dist/update/index.d.ts +78 -0
- package/dist/update/index.d.ts.map +1 -0
- package/dist/update/index.js +285 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/templates/default-instance/instruction/.cursorrules +6 -0
- package/templates/default-instance/instruction/.windsurfrules +6 -0
- package/templates/default-instance/instruction/CLAUDE.md +4 -0
- package/templates/default-instance/managed/agent.md +20 -0
- package/templates/default-instance/managed/bootstrap.md +7 -0
- package/templates/default-instance/managed/index-idea.md +5 -0
- package/templates/default-instance/managed/routing-idea.md +14 -0
- package/templates/default-instance/monolith/AGENT.md +1 -0
- package/templates/default-instance/monolith/context/maps/routing-map.md +4 -0
- package/templates/default-instance/monolith/context/maps/semantic-map.md +2 -0
- package/templates/default-instance/monolith/skills/INDEX.md +4 -0
- package/templates/default-instance/monolith/skills/idea-capture/SKILL.md +157 -0
- package/templates/default-instance/projectroot/AGENTS.md +4 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery contract, schema version 1 (ADR-021 §2-§4). Internal: not exported from `src/index.ts`.
|
|
3
|
+
*
|
|
4
|
+
* A fact says what the project's bytes show (`DETECTED`) or what a named rule derived from
|
|
5
|
+
* DETECTED facts (`INTERPRETED`). Three properties are kept apart on purpose and never
|
|
6
|
+
* folded into one another:
|
|
7
|
+
* - `completeness`: did the scan cover what it declared (`COMPLETE` | `PARTIAL`);
|
|
8
|
+
* - `reproducible`: would a second scan of the same unchanged tree return this result;
|
|
9
|
+
* - `level` of a fact: how it was obtained. There is no numeric confidence.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
export const DISCOVERY_SCHEMA_VERSION = 1;
|
|
13
|
+
/** The level is a function of the kind (ADR-021 §2): never chosen per fact, checked by the validator. */
|
|
14
|
+
export const FACT_LEVEL_BY_KIND = {
|
|
15
|
+
/** A directory exists in the inventory. */
|
|
16
|
+
DIRECTORY: 'DETECTED',
|
|
17
|
+
/** A file with a conventional name exists (the basis of an entrypoint candidate). */
|
|
18
|
+
FILE_PRESENT: 'DETECTED',
|
|
19
|
+
/**
|
|
20
|
+
* `value` files with extension `object` exist; `subject` is the language that extension maps to.
|
|
21
|
+
* An aggregate: `value` counts every file the traversal listed, `sources` is only a sample of them
|
|
22
|
+
* (see `sourceCoverage`).
|
|
23
|
+
*/
|
|
24
|
+
LANGUAGE_BY_EXTENSION: 'DETECTED',
|
|
25
|
+
/** A file with the name of a known manifest exists. Says nothing about its content or about runtime use. */
|
|
26
|
+
MANIFEST_PRESENT: 'DETECTED',
|
|
27
|
+
/** A manifest declares dependency `object` in section `value`. Not resolved, not installed, not verified. */
|
|
28
|
+
DEPENDENCY_DECLARED: 'DETECTED',
|
|
29
|
+
/** A manifest declares a script named `object`. The command is never stored and never run. */
|
|
30
|
+
SCRIPT_DECLARED: 'DETECTED',
|
|
31
|
+
/** A file name convention suggests `subject` may start the program. A candidate, never a verified entrypoint. */
|
|
32
|
+
ENTRYPOINT_CANDIDATE: 'INTERPRETED',
|
|
33
|
+
};
|
|
34
|
+
export function sourceCoverage(fact) {
|
|
35
|
+
if (fact.kind !== 'LANGUAGE_BY_EXTENSION')
|
|
36
|
+
return 'FULL';
|
|
37
|
+
return fact.sources.length >= Number(fact.value) ? 'FULL' : 'SAMPLE';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Closed list. The value says whether the code makes the result `PARTIAL` (something inside the
|
|
41
|
+
* declared scope was not extracted) or only reports a scope policy or an unanalysed area.
|
|
42
|
+
*/
|
|
43
|
+
export const EXTRACTION_DIAGNOSTIC_AFFECTS_COMPLETENESS = {
|
|
44
|
+
INPUT_PATH_REJECTED: true,
|
|
45
|
+
LIMIT_DIRECTORY_FACTS: true,
|
|
46
|
+
LIMIT_LANGUAGE_FACTS: true,
|
|
47
|
+
LIMIT_MANIFEST_FACTS: true,
|
|
48
|
+
LIMIT_MANIFEST_READS: true,
|
|
49
|
+
LIMIT_DEPENDENCIES: true,
|
|
50
|
+
LIMIT_SCRIPTS: true,
|
|
51
|
+
LIMIT_ENTRYPOINT_FACTS: true,
|
|
52
|
+
LIMIT_FACTS: true,
|
|
53
|
+
LIMIT_DIAGNOSTICS: true,
|
|
54
|
+
MANIFEST_READ_FAILED: true,
|
|
55
|
+
MANIFEST_TOO_LARGE: true,
|
|
56
|
+
MANIFEST_PARSE_ERROR: true,
|
|
57
|
+
MANIFEST_FIELD_INVALID: true,
|
|
58
|
+
MANIFEST_VALUE_REJECTED: true,
|
|
59
|
+
MANIFEST_EMPTY: false,
|
|
60
|
+
MANIFEST_NOT_REGULAR_FILE: false,
|
|
61
|
+
MANIFEST_CONTENT_NOT_ANALYZED: false,
|
|
62
|
+
NESTED_MONOLITH_STATE_EXCLUDED: false,
|
|
63
|
+
NESTED_MONOLITH_UNCONFIRMED: false,
|
|
64
|
+
};
|
|
65
|
+
/** Whether a second scan of the same unchanged tree must return the same diagnostic. */
|
|
66
|
+
export const EXTRACTION_DIAGNOSTIC_IS_STABLE = {
|
|
67
|
+
INPUT_PATH_REJECTED: true,
|
|
68
|
+
LIMIT_DIRECTORY_FACTS: true,
|
|
69
|
+
LIMIT_LANGUAGE_FACTS: true,
|
|
70
|
+
LIMIT_MANIFEST_FACTS: true,
|
|
71
|
+
LIMIT_MANIFEST_READS: true,
|
|
72
|
+
LIMIT_DEPENDENCIES: true,
|
|
73
|
+
LIMIT_SCRIPTS: true,
|
|
74
|
+
LIMIT_ENTRYPOINT_FACTS: true,
|
|
75
|
+
LIMIT_FACTS: true,
|
|
76
|
+
LIMIT_DIAGNOSTICS: true,
|
|
77
|
+
// A read can fail because the file changed or the disk misbehaved: not a function of the tree alone.
|
|
78
|
+
MANIFEST_READ_FAILED: false,
|
|
79
|
+
MANIFEST_TOO_LARGE: true,
|
|
80
|
+
MANIFEST_PARSE_ERROR: true,
|
|
81
|
+
MANIFEST_FIELD_INVALID: true,
|
|
82
|
+
MANIFEST_VALUE_REJECTED: true,
|
|
83
|
+
MANIFEST_EMPTY: true,
|
|
84
|
+
MANIFEST_NOT_REGULAR_FILE: true,
|
|
85
|
+
MANIFEST_CONTENT_NOT_ANALYZED: true,
|
|
86
|
+
NESTED_MONOLITH_STATE_EXCLUDED: true,
|
|
87
|
+
NESTED_MONOLITH_UNCONFIRMED: true,
|
|
88
|
+
};
|
|
89
|
+
/** Extraction cannot proceed safely or produced a result the validator rejects: no result exists (FAILED). */
|
|
90
|
+
export class DiscoveryError extends Error {
|
|
91
|
+
code;
|
|
92
|
+
constructor(code, message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = 'DiscoveryError';
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** UTF-16 code-unit order; never `localeCompare` (ADR-021 §4). */
|
|
99
|
+
export function compareUtf16(a, b) {
|
|
100
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
101
|
+
}
|
|
102
|
+
/** 16 hex of sha256 over the canonical tuple. Provenance, level, rule and positions are excluded. */
|
|
103
|
+
export function computeFactId(kind, subject, object, value) {
|
|
104
|
+
const canonical = JSON.stringify([DISCOVERY_SCHEMA_VERSION, kind, subject, object ?? null, value ?? null]);
|
|
105
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
/** A project-relative POSIX path with no empty, `.` or `..` segment, no NUL/control/backslash and no drive prefix. */
|
|
108
|
+
export function isSafeRelativePath(p) {
|
|
109
|
+
if (typeof p !== 'string' || p.length === 0 || p.length > 4096)
|
|
110
|
+
return false;
|
|
111
|
+
if (p.startsWith('/') || p.endsWith('/') || /^[A-Za-z]:/.test(p))
|
|
112
|
+
return false;
|
|
113
|
+
// eslint-disable-next-line no-control-regex
|
|
114
|
+
if (/[\u0000-\u001f\u007f\\�]/.test(p))
|
|
115
|
+
return false;
|
|
116
|
+
return p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..');
|
|
117
|
+
}
|
|
118
|
+
export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
119
|
+
/** RFC 6901 token escaping. */
|
|
120
|
+
export const pointerToken = (s) => s.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
121
|
+
// Control, bidirectional and replacement characters and lone surrogates: not kept in a fact.
|
|
122
|
+
// eslint-disable-next-line no-control-regex
|
|
123
|
+
const UNSAFE_NAME = /[\u0000-\u001f\u007f-\u009f--�]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
124
|
+
export function isKeepableName(name, max) {
|
|
125
|
+
return name.length > 0 && name.length <= max && !UNSAFE_NAME.test(name);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Diagnostic order: completeness-affecting extraction codes (0), traversal codes (1), the other
|
|
129
|
+
* extraction codes (2); then code, path, message, count. `count` closes the tie so that no two
|
|
130
|
+
* different diagnostics compare equal.
|
|
131
|
+
*/
|
|
132
|
+
export function compareDiagnostics(a, b) {
|
|
133
|
+
const rank = (d) => d.code.startsWith('TRAVERSAL_') ? 1 : EXTRACTION_DIAGNOSTIC_AFFECTS_COMPLETENESS[d.code] ? 0 : 2;
|
|
134
|
+
return rank(a) - rank(b) || compareUtf16(a.code, b.code) || compareUtf16(a.path ?? '', b.path ?? '') || compareUtf16(a.message, b.message) || (a.count ?? 0) - (b.count ?? 0);
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Validator
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/** Output hygiene of the validator itself, not a limit of the contract. */
|
|
140
|
+
const MAX_VIOLATIONS = 100;
|
|
141
|
+
const LIMIT_NAMES = [
|
|
142
|
+
'maxDirectoryFacts',
|
|
143
|
+
'maxDirectoryDepth',
|
|
144
|
+
'maxLanguageFacts',
|
|
145
|
+
'maxManifestFacts',
|
|
146
|
+
'maxManifestReads',
|
|
147
|
+
'maxManifestBytes',
|
|
148
|
+
'maxDependenciesPerManifest',
|
|
149
|
+
'maxScriptsPerManifest',
|
|
150
|
+
'maxEntrypointFacts',
|
|
151
|
+
'maxFacts',
|
|
152
|
+
'maxDiagnostics',
|
|
153
|
+
'maxNameLength',
|
|
154
|
+
'maxSourcesPerFact',
|
|
155
|
+
];
|
|
156
|
+
const STAT_NAMES = ['inputEntries', 'excludedEntries', 'filesWithoutKnownLanguage', 'manifestsFound', 'manifestsRead', 'facts'];
|
|
157
|
+
const OBSERVATIONS = ['directory-entry', 'file-entry', 'extension', 'file-name', 'manifest-field'];
|
|
158
|
+
const FACT_KEYS = ['id', 'kind', 'level', 'subject', 'object', 'value', 'rule', 'basis', 'sources'];
|
|
159
|
+
const SOURCE_KEYS = ['path', 'observation', 'field'];
|
|
160
|
+
const DIAGNOSTIC_KEYS = ['code', 'path', 'count', 'message'];
|
|
161
|
+
const RESULT_KEYS = ['schemaVersion', 'limits', 'completeness', 'reproducible', 'traversal', 'stats', 'facts', 'diagnostics'];
|
|
162
|
+
/** The observation that backs a fact of each kind. */
|
|
163
|
+
const OBSERVATION_BY_KIND = {
|
|
164
|
+
DIRECTORY: 'directory-entry',
|
|
165
|
+
FILE_PRESENT: 'file-entry',
|
|
166
|
+
LANGUAGE_BY_EXTENSION: 'extension',
|
|
167
|
+
MANIFEST_PRESENT: 'file-name',
|
|
168
|
+
DEPENDENCY_DECLARED: 'manifest-field',
|
|
169
|
+
SCRIPT_DECLARED: 'manifest-field',
|
|
170
|
+
ENTRYPOINT_CANDIDATE: 'file-name',
|
|
171
|
+
};
|
|
172
|
+
const HEX16 = /^[0-9a-f]{16}$/;
|
|
173
|
+
const CANONICAL_COUNT = /^[1-9][0-9]*$/;
|
|
174
|
+
const TRAVERSAL_CODE = /^TRAVERSAL_[A-Z][A-Z0-9_]{0,63}$/;
|
|
175
|
+
// eslint-disable-next-line no-control-regex
|
|
176
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
177
|
+
// An absolute POSIX path (at the start or after a space, quote, bracket, `=` or `:`), a drive path or a UNC path.
|
|
178
|
+
const ABSOLUTE_PATH_IN_TEXT = /(^|[\s"'`(=:])\/[^\s/]|[A-Za-z]:[\\/]|\\\\[^\s\\]/;
|
|
179
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
180
|
+
const isCount = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
181
|
+
const own = (o, k) => (Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
|
182
|
+
function factKey(f) {
|
|
183
|
+
return [f.kind, f.subject, f.object ?? '', f.value ?? ''];
|
|
184
|
+
}
|
|
185
|
+
export function compareFacts(a, b) {
|
|
186
|
+
const ka = factKey(a);
|
|
187
|
+
const kb = factKey(b);
|
|
188
|
+
for (let i = 0; i < ka.length; i++) {
|
|
189
|
+
const c = compareUtf16(ka[i], kb[i]);
|
|
190
|
+
if (c !== 0)
|
|
191
|
+
return c;
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
export function compareSources(a, b) {
|
|
196
|
+
return compareUtf16(a.path, b.path) || compareUtf16(a.observation, b.observation) || compareUtf16(a.field ?? '', b.field ?? '');
|
|
197
|
+
}
|
|
198
|
+
function extensionOfPath(path) {
|
|
199
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
200
|
+
const dot = name.lastIndexOf('.');
|
|
201
|
+
return dot <= 0 || dot === name.length - 1 ? undefined : name.slice(dot).toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check of a `DiscoveryResult` against the schema-1 contract. Returns the violations (empty = valid);
|
|
205
|
+
* `extractProjectFacts` throws `DiscoveryError('RESULT_INVALID')` rather than return a result that
|
|
206
|
+
* fails it (FAILED, ADR-021 §5).
|
|
207
|
+
*
|
|
208
|
+
* Input is `unknown` on purpose: a malformed value yields violations, never a `TypeError`. A violation
|
|
209
|
+
* names a location (`facts[3]`) and a rule, and never repeats a value from the input, so an untrusted
|
|
210
|
+
* result cannot reach a log through it. At most 100 violations are returned.
|
|
211
|
+
*
|
|
212
|
+
* Scope: plain JSON-like data (a producer's output or `JSON.parse` output). An object with accessors
|
|
213
|
+
* that throw, or a `Proxy`, is not defended against (ADR-021 does not require it). The validator is
|
|
214
|
+
* not a general schema library: it checks this contract and nothing else.
|
|
215
|
+
*/
|
|
216
|
+
export function validateDiscoveryResult(result) {
|
|
217
|
+
const bad = [];
|
|
218
|
+
const fail = (m) => {
|
|
219
|
+
if (bad.length < MAX_VIOLATIONS)
|
|
220
|
+
bad.push(m);
|
|
221
|
+
};
|
|
222
|
+
if (!isObject(result))
|
|
223
|
+
return ['result: not an object'];
|
|
224
|
+
const keysOk = (o, allowed, at) => {
|
|
225
|
+
for (const k of Object.keys(o))
|
|
226
|
+
if (!allowed.includes(k))
|
|
227
|
+
fail(`${at}: unknown property`);
|
|
228
|
+
};
|
|
229
|
+
keysOk(result, RESULT_KEYS, 'result');
|
|
230
|
+
if (result.schemaVersion !== DISCOVERY_SCHEMA_VERSION)
|
|
231
|
+
fail('schemaVersion');
|
|
232
|
+
// limits
|
|
233
|
+
const limits = {};
|
|
234
|
+
let limitsOk = false;
|
|
235
|
+
const rawLimits = own(result, 'limits');
|
|
236
|
+
if (!isObject(rawLimits))
|
|
237
|
+
fail('limits: not an object');
|
|
238
|
+
else {
|
|
239
|
+
limitsOk = true;
|
|
240
|
+
keysOk(rawLimits, LIMIT_NAMES, 'limits');
|
|
241
|
+
for (const k of LIMIT_NAMES) {
|
|
242
|
+
const v = own(rawLimits, k);
|
|
243
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 1) {
|
|
244
|
+
fail(`limits.${k}: not a positive integer`);
|
|
245
|
+
limitsOk = false;
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
limits[k] = v;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// completeness, reproducibility
|
|
252
|
+
const verdict = (v) => v === 'COMPLETE' || v === 'PARTIAL';
|
|
253
|
+
const completeness = own(result, 'completeness');
|
|
254
|
+
const reproducible = own(result, 'reproducible');
|
|
255
|
+
if (!verdict(completeness))
|
|
256
|
+
fail('completeness: not COMPLETE or PARTIAL');
|
|
257
|
+
if (typeof reproducible !== 'boolean')
|
|
258
|
+
fail('reproducible: not a boolean');
|
|
259
|
+
const traversal = own(result, 'traversal');
|
|
260
|
+
let traversalPartial = false;
|
|
261
|
+
let traversalUnstable = false;
|
|
262
|
+
if (!isObject(traversal))
|
|
263
|
+
fail('traversal: not an object');
|
|
264
|
+
else {
|
|
265
|
+
keysOk(traversal, ['completeness', 'reproducible'], 'traversal');
|
|
266
|
+
if (!verdict(own(traversal, 'completeness')))
|
|
267
|
+
fail('traversal.completeness: not COMPLETE or PARTIAL');
|
|
268
|
+
if (typeof own(traversal, 'reproducible') !== 'boolean')
|
|
269
|
+
fail('traversal.reproducible: not a boolean');
|
|
270
|
+
traversalPartial = own(traversal, 'completeness') === 'PARTIAL';
|
|
271
|
+
traversalUnstable = own(traversal, 'reproducible') === false;
|
|
272
|
+
}
|
|
273
|
+
// stats
|
|
274
|
+
const stats = own(result, 'stats');
|
|
275
|
+
let statsOk = false;
|
|
276
|
+
if (!isObject(stats))
|
|
277
|
+
fail('stats: not an object');
|
|
278
|
+
else {
|
|
279
|
+
statsOk = true;
|
|
280
|
+
keysOk(stats, STAT_NAMES, 'stats');
|
|
281
|
+
for (const k of STAT_NAMES) {
|
|
282
|
+
if (!isCount(own(stats, k))) {
|
|
283
|
+
fail(`stats.${k}: not a non-negative integer`);
|
|
284
|
+
statsOk = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// facts: size first, so an oversized array is refused without walking it
|
|
289
|
+
const facts = own(result, 'facts');
|
|
290
|
+
const validFacts = [];
|
|
291
|
+
const perKind = new Map();
|
|
292
|
+
const perManifest = new Map();
|
|
293
|
+
if (!Array.isArray(facts))
|
|
294
|
+
fail('facts: not an array');
|
|
295
|
+
else if (limitsOk && facts.length > limits.maxFacts)
|
|
296
|
+
fail('facts: more than limits.maxFacts');
|
|
297
|
+
else {
|
|
298
|
+
const byId = new Map();
|
|
299
|
+
for (const f of facts)
|
|
300
|
+
if (isObject(f) && typeof f.id === 'string')
|
|
301
|
+
byId.set(f.id, f);
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
let prev;
|
|
304
|
+
facts.forEach((raw, i) => {
|
|
305
|
+
const at = `facts[${i}]`;
|
|
306
|
+
if (!isObject(raw))
|
|
307
|
+
return fail(`${at}: not an object`);
|
|
308
|
+
keysOk(raw, FACT_KEYS, at);
|
|
309
|
+
const { id, kind, level, subject, object, value, rule, basis, sources } = raw;
|
|
310
|
+
const strOrUndef = (v) => v === undefined || (typeof v === 'string' && v.length > 0 && !CONTROL.test(v));
|
|
311
|
+
const kindOk = typeof kind === 'string' && Object.prototype.hasOwnProperty.call(FACT_LEVEL_BY_KIND, kind);
|
|
312
|
+
let shapeOk = true;
|
|
313
|
+
const need = (ok, what) => {
|
|
314
|
+
if (!ok) {
|
|
315
|
+
fail(`${at}: ${what}`);
|
|
316
|
+
shapeOk = false;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
need(typeof id === 'string' && HEX16.test(id), 'id');
|
|
320
|
+
need(kindOk, 'kind');
|
|
321
|
+
need(kindOk && FACT_LEVEL_BY_KIND[kind] === level, 'level');
|
|
322
|
+
need(typeof subject === 'string' && subject.length > 0 && !CONTROL.test(subject), 'subject');
|
|
323
|
+
need(strOrUndef(object), 'object');
|
|
324
|
+
need(strOrUndef(value), 'value');
|
|
325
|
+
need(typeof rule === 'string' && rule.length > 0 && !CONTROL.test(rule), 'rule');
|
|
326
|
+
need(Array.isArray(sources), 'sources: not an array');
|
|
327
|
+
need(basis === undefined || (Array.isArray(basis) && basis.every((b) => typeof b === 'string')), 'basis: not an array of strings');
|
|
328
|
+
if (!shapeOk)
|
|
329
|
+
return;
|
|
330
|
+
const fact = raw;
|
|
331
|
+
if (fact.id !== computeFactId(fact.kind, fact.subject, fact.object, fact.value))
|
|
332
|
+
fail(`${at}: id does not match its content`);
|
|
333
|
+
if (seen.has(fact.id))
|
|
334
|
+
fail(`${at}: duplicate id`);
|
|
335
|
+
seen.add(fact.id);
|
|
336
|
+
perKind.set(fact.kind, (perKind.get(fact.kind) ?? 0) + 1);
|
|
337
|
+
// kind-specific shape
|
|
338
|
+
const pathSubject = fact.kind === 'DIRECTORY' || fact.kind === 'FILE_PRESENT' || fact.kind === 'MANIFEST_PRESENT' || fact.kind === 'ENTRYPOINT_CANDIDATE';
|
|
339
|
+
if (pathSubject && !isSafeRelativePath(fact.subject))
|
|
340
|
+
fail(`${at}: unsafe subject path`);
|
|
341
|
+
if (pathSubject && fact.kind === 'DIRECTORY' && limitsOk && fact.subject.split('/').length > limits.maxDirectoryDepth)
|
|
342
|
+
fail(`${at}: directory deeper than limits.maxDirectoryDepth`);
|
|
343
|
+
const noObject = fact.kind === 'DIRECTORY' || fact.kind === 'FILE_PRESENT' || fact.kind === 'ENTRYPOINT_CANDIDATE';
|
|
344
|
+
const noValue = fact.kind === 'DIRECTORY' || fact.kind === 'FILE_PRESENT' || fact.kind === 'MANIFEST_PRESENT' || fact.kind === 'SCRIPT_DECLARED';
|
|
345
|
+
if (noObject && fact.object !== undefined)
|
|
346
|
+
fail(`${at}: object not allowed for this kind`);
|
|
347
|
+
if (noValue && fact.value !== undefined)
|
|
348
|
+
fail(`${at}: value not allowed for this kind`);
|
|
349
|
+
if (!noObject && fact.object === undefined)
|
|
350
|
+
fail(`${at}: object required for this kind`);
|
|
351
|
+
if (fact.kind === 'ENTRYPOINT_CANDIDATE' && fact.value !== 'candidate')
|
|
352
|
+
fail(`${at}: value must be "candidate"`);
|
|
353
|
+
if (fact.kind === 'LANGUAGE_BY_EXTENSION') {
|
|
354
|
+
if (!/^\.[^./\s]+$/.test(fact.object ?? '') || fact.object !== (fact.object ?? '').toLowerCase())
|
|
355
|
+
fail(`${at}: object is not a lower-case extension`);
|
|
356
|
+
if (!CANONICAL_COUNT.test(fact.value ?? '') || !Number.isSafeInteger(Number(fact.value)))
|
|
357
|
+
fail(`${at}: value is not a positive count`);
|
|
358
|
+
}
|
|
359
|
+
if (fact.kind === 'DEPENDENCY_DECLARED' && !DEPENDENCY_SECTIONS.includes(fact.value ?? ''))
|
|
360
|
+
fail(`${at}: value is not a dependency section`);
|
|
361
|
+
if (fact.kind === 'DEPENDENCY_DECLARED' || fact.kind === 'SCRIPT_DECLARED') {
|
|
362
|
+
if (!isSafeRelativePath(fact.subject))
|
|
363
|
+
fail(`${at}: unsafe subject path`);
|
|
364
|
+
if (limitsOk && !isKeepableName(fact.object ?? '', limits.maxNameLength))
|
|
365
|
+
fail(`${at}: object is not a keepable name`);
|
|
366
|
+
perManifest.set(`${fact.kind}\u0000${fact.subject}`, (perManifest.get(`${fact.kind}\u0000${fact.subject}`) ?? 0) + 1);
|
|
367
|
+
}
|
|
368
|
+
// level and references
|
|
369
|
+
if (fact.level === 'INTERPRETED') {
|
|
370
|
+
if (!fact.basis || fact.basis.length === 0)
|
|
371
|
+
fail(`${at}: interpreted without basis`);
|
|
372
|
+
const refs = new Set();
|
|
373
|
+
for (const b of fact.basis ?? []) {
|
|
374
|
+
if (refs.has(b))
|
|
375
|
+
fail(`${at}: duplicate basis reference`);
|
|
376
|
+
refs.add(b);
|
|
377
|
+
const target = byId.get(b);
|
|
378
|
+
if (target === undefined)
|
|
379
|
+
fail(`${at}: basis refers to no fact`);
|
|
380
|
+
else if (target.level !== 'DETECTED')
|
|
381
|
+
fail(`${at}: basis is not a DETECTED fact`);
|
|
382
|
+
else if (fact.kind === 'ENTRYPOINT_CANDIDATE' && (target.kind !== 'FILE_PRESENT' || target.subject !== fact.subject))
|
|
383
|
+
fail(`${at}: basis is not the FILE_PRESENT fact of the same path`);
|
|
384
|
+
}
|
|
385
|
+
for (let j = 1; j < (fact.basis?.length ?? 0); j++)
|
|
386
|
+
if (compareUtf16(fact.basis[j - 1], fact.basis[j]) > 0)
|
|
387
|
+
fail(`${at}: basis order`);
|
|
388
|
+
}
|
|
389
|
+
else if (fact.basis !== undefined)
|
|
390
|
+
fail(`${at}: basis on a DETECTED fact`);
|
|
391
|
+
// sources
|
|
392
|
+
const srcs = fact.sources;
|
|
393
|
+
if (srcs.length === 0)
|
|
394
|
+
fail(`${at}: no source`);
|
|
395
|
+
else if (limitsOk && srcs.length > limits.maxSourcesPerFact && fact.kind === 'LANGUAGE_BY_EXTENSION')
|
|
396
|
+
fail(`${at}: more sources than limits.maxSourcesPerFact`);
|
|
397
|
+
else if (srcs.length > 1 && !(fact.kind === 'LANGUAGE_BY_EXTENSION'))
|
|
398
|
+
fail(`${at}: more than one source for this kind`);
|
|
399
|
+
let sourcesOk = true;
|
|
400
|
+
srcs.forEach((sr, j) => {
|
|
401
|
+
const sat = `${at}.sources[${j}]`;
|
|
402
|
+
if (!isObject(sr)) {
|
|
403
|
+
sourcesOk = false;
|
|
404
|
+
return fail(`${sat}: not an object`);
|
|
405
|
+
}
|
|
406
|
+
keysOk(sr, SOURCE_KEYS, sat);
|
|
407
|
+
const { path, observation, field } = sr;
|
|
408
|
+
if (!isSafeRelativePath(path)) {
|
|
409
|
+
sourcesOk = false;
|
|
410
|
+
fail(`${sat}: unsafe source path`);
|
|
411
|
+
}
|
|
412
|
+
if (!OBSERVATIONS.includes(observation)) {
|
|
413
|
+
sourcesOk = false;
|
|
414
|
+
return fail(`${sat}: unknown observation`);
|
|
415
|
+
}
|
|
416
|
+
if (observation !== OBSERVATION_BY_KIND[fact.kind])
|
|
417
|
+
fail(`${sat}: observation does not match the kind`);
|
|
418
|
+
if (observation === 'manifest-field') {
|
|
419
|
+
if (typeof field !== 'string' || !field.startsWith('/') || CONTROL.test(field))
|
|
420
|
+
fail(`${sat}: manifest-field source without a JSON Pointer`);
|
|
421
|
+
else if (fact.kind === 'DEPENDENCY_DECLARED' && field !== `/${fact.value}/${pointerToken(fact.object)}`)
|
|
422
|
+
fail(`${sat}: field does not point at the declared dependency`);
|
|
423
|
+
else if (fact.kind === 'SCRIPT_DECLARED' && field !== `/scripts/${pointerToken(fact.object)}`)
|
|
424
|
+
fail(`${sat}: field does not point at the declared script`);
|
|
425
|
+
}
|
|
426
|
+
else if (field !== undefined)
|
|
427
|
+
fail(`${sat}: field only belongs to a manifest-field source`);
|
|
428
|
+
if (typeof path === 'string' && fact.kind !== 'LANGUAGE_BY_EXTENSION' && path !== fact.subject)
|
|
429
|
+
fail(`${sat}: source path differs from the subject`);
|
|
430
|
+
if (typeof path === 'string' && fact.kind === 'LANGUAGE_BY_EXTENSION' && extensionOfPath(path) !== fact.object)
|
|
431
|
+
fail(`${sat}: source extension differs from the fact`);
|
|
432
|
+
});
|
|
433
|
+
if (sourcesOk) {
|
|
434
|
+
const list = srcs;
|
|
435
|
+
for (let j = 1; j < list.length; j++) {
|
|
436
|
+
const c = compareSources(list[j - 1], list[j]);
|
|
437
|
+
if (c > 0)
|
|
438
|
+
fail(`${at}: sources order`);
|
|
439
|
+
if (c === 0)
|
|
440
|
+
fail(`${at}: duplicate source`);
|
|
441
|
+
}
|
|
442
|
+
// Provenance semantics: a language aggregate lists min(count, maxSourcesPerFact) files, so its
|
|
443
|
+
// coverage (FULL or SAMPLE) follows from the two numbers and cannot be declared falsely.
|
|
444
|
+
if (fact.kind === 'LANGUAGE_BY_EXTENSION' && limitsOk && CANONICAL_COUNT.test(fact.value ?? '') && list.length !== Math.min(Number(fact.value), limits.maxSourcesPerFact)) {
|
|
445
|
+
fail(`${at}: source count is not min(count, limits.maxSourcesPerFact)`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (prev && compareFacts(prev, fact) > 0)
|
|
449
|
+
fail(`${at}: facts order`);
|
|
450
|
+
prev = fact;
|
|
451
|
+
validFacts.push(fact);
|
|
452
|
+
});
|
|
453
|
+
if (limitsOk) {
|
|
454
|
+
const cap = [
|
|
455
|
+
['DIRECTORY', 'maxDirectoryFacts'],
|
|
456
|
+
['LANGUAGE_BY_EXTENSION', 'maxLanguageFacts'],
|
|
457
|
+
['MANIFEST_PRESENT', 'maxManifestFacts'],
|
|
458
|
+
['ENTRYPOINT_CANDIDATE', 'maxEntrypointFacts'],
|
|
459
|
+
];
|
|
460
|
+
for (const [kind, limit] of cap)
|
|
461
|
+
if ((perKind.get(kind) ?? 0) > limits[limit])
|
|
462
|
+
fail(`facts: more ${kind} facts than limits.${limit}`);
|
|
463
|
+
for (const [key, n] of perManifest) {
|
|
464
|
+
const dependency = key.startsWith('DEPENDENCY_DECLARED');
|
|
465
|
+
if (n > limits[dependency ? 'maxDependenciesPerManifest' : 'maxScriptsPerManifest'])
|
|
466
|
+
fail(`facts: more ${dependency ? 'dependency' : 'script'} facts for one manifest than the limit`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (statsOk && Array.isArray(facts)) {
|
|
470
|
+
const st = stats;
|
|
471
|
+
if (st.facts !== facts.length)
|
|
472
|
+
fail('stats.facts: differs from the number of facts');
|
|
473
|
+
if (st.manifestsRead > st.manifestsFound)
|
|
474
|
+
fail('stats.manifestsRead: more than manifestsFound');
|
|
475
|
+
if ((perKind.get('MANIFEST_PRESENT') ?? 0) > st.manifestsFound)
|
|
476
|
+
fail('stats.manifestsFound: fewer than the manifest facts');
|
|
477
|
+
if (limitsOk && st.manifestsRead > limits.maxManifestReads)
|
|
478
|
+
fail('stats.manifestsRead: more than limits.maxManifestReads');
|
|
479
|
+
if (st.excludedEntries > st.inputEntries)
|
|
480
|
+
fail('stats.excludedEntries: more than inputEntries');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// diagnostics
|
|
484
|
+
const diagnostics = own(result, 'diagnostics');
|
|
485
|
+
let affecting = false;
|
|
486
|
+
let unstable = false;
|
|
487
|
+
if (!Array.isArray(diagnostics))
|
|
488
|
+
fail('diagnostics: not an array');
|
|
489
|
+
else if (limitsOk && diagnostics.length > limits.maxDiagnostics)
|
|
490
|
+
fail('diagnostics: more than limits.maxDiagnostics');
|
|
491
|
+
else {
|
|
492
|
+
const parsed = [];
|
|
493
|
+
let allOk = true;
|
|
494
|
+
diagnostics.forEach((raw, i) => {
|
|
495
|
+
const at = `diagnostics[${i}]`;
|
|
496
|
+
if (!isObject(raw)) {
|
|
497
|
+
allOk = false;
|
|
498
|
+
return fail(`${at}: not an object`);
|
|
499
|
+
}
|
|
500
|
+
keysOk(raw, DIAGNOSTIC_KEYS, at);
|
|
501
|
+
const { code, path, count, message } = raw;
|
|
502
|
+
const known = typeof code === 'string' && Object.prototype.hasOwnProperty.call(EXTRACTION_DIAGNOSTIC_AFFECTS_COMPLETENESS, code);
|
|
503
|
+
const traversalCode = typeof code === 'string' && TRAVERSAL_CODE.test(code);
|
|
504
|
+
let ok = true;
|
|
505
|
+
if (!known && !traversalCode) {
|
|
506
|
+
fail(`${at}: unknown code`);
|
|
507
|
+
ok = false;
|
|
508
|
+
}
|
|
509
|
+
if (path !== undefined && !isSafeRelativePath(path)) {
|
|
510
|
+
fail(`${at}: unsafe path`);
|
|
511
|
+
ok = false;
|
|
512
|
+
}
|
|
513
|
+
if (count !== undefined && !(isCount(count) && count >= 1)) {
|
|
514
|
+
fail(`${at}: count is not a positive integer`);
|
|
515
|
+
ok = false;
|
|
516
|
+
}
|
|
517
|
+
if (typeof message !== 'string' || message.length === 0 || CONTROL.test(message)) {
|
|
518
|
+
fail(`${at}: message is not plain text`);
|
|
519
|
+
ok = false;
|
|
520
|
+
}
|
|
521
|
+
else if (ABSOLUTE_PATH_IN_TEXT.test(message)) {
|
|
522
|
+
fail(`${at}: message contains an absolute path`);
|
|
523
|
+
ok = false;
|
|
524
|
+
}
|
|
525
|
+
if (!ok) {
|
|
526
|
+
allOk = false;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (known) {
|
|
530
|
+
if (EXTRACTION_DIAGNOSTIC_AFFECTS_COMPLETENESS[code])
|
|
531
|
+
affecting = true;
|
|
532
|
+
if (!EXTRACTION_DIAGNOSTIC_IS_STABLE[code])
|
|
533
|
+
unstable = true;
|
|
534
|
+
}
|
|
535
|
+
parsed.push(raw);
|
|
536
|
+
});
|
|
537
|
+
if (allOk) {
|
|
538
|
+
// A LIMIT_DIAGNOSTICS summary heads the list whatever its rank; the rest is sorted.
|
|
539
|
+
parsed.forEach((d, i) => {
|
|
540
|
+
if (d.code === 'LIMIT_DIAGNOSTICS' && i !== 0)
|
|
541
|
+
fail(`diagnostics[${i}]: the LIMIT_DIAGNOSTICS summary must come first`);
|
|
542
|
+
if (i === 0 || (i === 1 && parsed[0].code === 'LIMIT_DIAGNOSTICS'))
|
|
543
|
+
return;
|
|
544
|
+
if (compareDiagnostics(parsed[i - 1], d) > 0)
|
|
545
|
+
fail(`diagnostics[${i}]: order`);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Internal consistency of the verdicts
|
|
550
|
+
if (completeness === 'COMPLETE' && (traversalPartial || affecting))
|
|
551
|
+
fail('completeness: COMPLETE although the traversal or a diagnostic says PARTIAL');
|
|
552
|
+
if (reproducible === true && (traversalUnstable || unstable))
|
|
553
|
+
fail('reproducible: true although the traversal or a diagnostic says it is not');
|
|
554
|
+
return bad;
|
|
555
|
+
}
|